refactor: download

- download task manager
- fix installation summary update
This commit is contained in:
rhunk 2023-08-04 12:17:19 +02:00
parent 2ff8a69403
commit 3df11aadb8
22 changed files with 152 additions and 180 deletions

View File

@ -8,6 +8,7 @@ import androidx.documentfile.provider.DocumentFile
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.core.config.ModConfig import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.download.DownloadTaskManager
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo
import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo
@ -17,24 +18,40 @@ import java.lang.ref.WeakReference
import kotlin.system.exitProcess import kotlin.system.exitProcess
class RemoteSideContext( class RemoteSideContext(
val androidContext: Context ctx: Context
) { ) {
private var _context: WeakReference<Context> = WeakReference(ctx)
private var _activity: WeakReference<Activity>? = null private var _activity: WeakReference<Activity>? = null
var androidContext: Context
get() = synchronized(this) {
_context.get() ?: error("Context is null")
}
set(value) { synchronized(this) {
_context.clear(); _context = WeakReference(value)
} }
var activity: Activity? var activity: Activity?
get() = _activity?.get() get() = _activity?.get()
set(value) { _activity = WeakReference(value) } set(value) { _activity?.clear(); _activity = WeakReference(value) }
val config = ModConfig() val config = ModConfig()
val translation = LocaleWrapper() val translation = LocaleWrapper()
val mappings = MappingsWrapper(androidContext) val mappings = MappingsWrapper(androidContext)
val downloadTaskManager = DownloadTaskManager()
init { init {
config.loadFromContext(androidContext) runCatching {
translation.userLocale = config.locale config.loadFromContext(androidContext)
translation.loadFromContext(androidContext) translation.userLocale = config.locale
mappings.apply { translation.loadFromContext(androidContext)
loadFromContext(androidContext) mappings.apply {
init() loadFromContext(androidContext)
init()
}
downloadTaskManager.init(androidContext)
}.onFailure {
Logger.error("Failed to initialize RemoteSideContext", it)
} }
} }

View File

@ -1,15 +1,19 @@
package me.rhunk.snapenhance package me.rhunk.snapenhance
import android.content.Context import android.content.Context
import java.lang.ref.WeakReference
object SharedContextHolder { object SharedContextHolder {
private lateinit var _remoteSideContext: WeakReference<RemoteSideContext> private lateinit var _remoteSideContext: RemoteSideContext
fun remote(context: Context): RemoteSideContext { fun remote(context: Context): RemoteSideContext {
if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) { if (!::_remoteSideContext.isInitialized) {
_remoteSideContext = WeakReference(RemoteSideContext(context.applicationContext)) _remoteSideContext = RemoteSideContext(context)
} }
return _remoteSideContext.get()!!
if (_remoteSideContext.androidContext != context) {
_remoteSideContext.androidContext = context
}
return _remoteSideContext
} }
} }

View File

@ -4,11 +4,10 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
class BridgeService : Service() { class BridgeService : Service() {
@ -105,10 +104,10 @@ class BridgeService : Service() {
} }
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) { override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
SharedContextHolder.remote(this@BridgeService) DownloadProcessor(
//TODO: refactor shared context remoteSideContext = SharedContextHolder.remote(this@BridgeService),
SharedContext.ensureInitialized(this@BridgeService) callback = callback
DownloadProcessor(this@BridgeService, callback).onReceive(intent) ).onReceive(intent)
} }
} }
} }

View File

@ -1,7 +1,6 @@
package me.rhunk.snapenhance.download package me.rhunk.snapenhance.download
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
@ -16,17 +15,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.DownloadMediaType
import me.rhunk.snapenhance.download.data.DownloadMetadata import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.DownloadRequest import me.rhunk.snapenhance.download.data.DownloadRequest
import me.rhunk.snapenhance.download.data.DownloadStage
import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadMediaType
import me.rhunk.snapenhance.download.enums.DownloadStage
import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import java.io.File import java.io.File
@ -56,7 +55,7 @@ data class DownloadedFile(
*/ */
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
class DownloadProcessor ( class DownloadProcessor (
private val context: Context, private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback private val callback: DownloadCallback
) { ) {
@ -69,11 +68,29 @@ class DownloadProcessor (
} }
private fun fallbackToast(message: Any) { private fun fallbackToast(message: Any) {
android.os.Handler(context.mainLooper).post { android.os.Handler(remoteSideContext.androidContext.mainLooper).post {
Toast.makeText(context, message.toString(), Toast.LENGTH_SHORT).show() Toast.makeText(remoteSideContext.androidContext, message.toString(), Toast.LENGTH_SHORT).show()
} }
} }
private fun callbackOnSuccess(path: String) = runCatching {
callback.onSuccess(path)
}.onFailure {
fallbackToast(it)
}
private fun callbackOnFailure(message: String, throwable: String? = null) = runCatching {
callback.onFailure(message, throwable)
}.onFailure {
fallbackToast("$message\n$throwable")
}
private fun callbackOnProgress(message: String) = runCatching {
callback.onProgress(message)
}.onFailure {
fallbackToast(it)
}
private fun extractZip(inputStream: InputStream): List<File> { private fun extractZip(inputStream: InputStream): List<File> {
val files = mutableListOf<File>() val files = mutableListOf<File>()
val zipInputStream = ZipInputStream(inputStream) val zipInputStream = ZipInputStream(inputStream)
@ -100,30 +117,20 @@ class DownloadProcessor (
return CipherInputStream(inputStream, cipher) return CipherInputStream(inputStream, cipher)
} }
private fun createNeededDirectories(file: File): File {
val directory = file.parentFile ?: return file
if (!directory.exists()) {
directory.mkdirs()
}
return file
}
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag")
private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) {
if (coroutineContext.job.isCancelled) return if (coroutineContext.job.isCancelled) return
val config by ModConfig().apply { loadFromContext(context) }
runCatching { runCatching {
val fileType = FileType.fromFile(inputFile) val fileType = FileType.fromFile(inputFile)
if (fileType == FileType.UNKNOWN) { if (fileType == FileType.UNKNOWN) {
callback.onFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null) callbackOnFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null)
return return
} }
val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
val outputFolder = DocumentFile.fromTreeUri(context, Uri.parse(config.downloader.saveFolder.get())) val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(remoteSideContext.config.root.downloader.saveFolder.get()))
?: throw Exception("Failed to open output folder") ?: throw Exception("Failed to open output folder")
val outputFileFolder = pendingDownload.metadata.outputPath.let { val outputFileFolder = pendingDownload.metadata.outputPath.let {
@ -137,7 +144,7 @@ class DownloadProcessor (
} }
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
val outputStream = context.contentResolver.openOutputStream(outputFile.uri)!! val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!
inputFile.inputStream().use { inputStream -> inputFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
@ -149,21 +156,17 @@ class DownloadProcessor (
runCatching { runCatching {
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE")
mediaScanIntent.setData(outputFile.uri) mediaScanIntent.setData(outputFile.uri)
context.sendBroadcast(mediaScanIntent) remoteSideContext.androidContext.sendBroadcast(mediaScanIntent)
}.onFailure { }.onFailure {
Logger.error("Failed to scan media file", it) Logger.error("Failed to scan media file", it)
callback.onFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message)
} }
Logger.debug("download complete") Logger.debug("download complete")
fileName.let { callbackOnSuccess(fileName)
runCatching { callback.onSuccess(it) }.onFailure { fallbackToast(it) }
}
}.onFailure { exception -> }.onFailure { exception ->
Logger.error(exception) Logger.error(exception)
translation.format("failed_gallery_toast", "error" to exception.toString()).let { callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message)
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
pendingDownload.downloadStage = DownloadStage.FAILED pendingDownload.downloadStage = DownloadStage.FAILED
} }
} }
@ -250,9 +253,7 @@ class DownloadProcessor (
val xmlData = dashPlaylistFile.outputStream() val xmlData = dashPlaylistFile.outputStream()
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData))
translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension).let { callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension))
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
}
val outputFile = File.createTempFile("dash", ".mp4") val outputFile = File.createTempFile("dash", ".mp4")
runCatching { runCatching {
MediaDownloaderHelper.downloadDashChapterFile( MediaDownloaderHelper.downloadDashChapterFile(
@ -264,9 +265,7 @@ class DownloadProcessor (
}.onFailure { exception -> }.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(exception) Logger.error(exception)
translation.format("failed_processing_toast", "error" to exception.toString()).let { callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.FAILED pendingDownloadObject.downloadStage = DownloadStage.FAILED
} }
@ -287,23 +286,24 @@ class DownloadProcessor (
val downloadMetadata = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java) val downloadMetadata = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java) val downloadRequest = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
SharedContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage -> remoteSideContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage ->
translation[if (downloadStage.isFinalStage) { translation[if (downloadStage.isFinalStage) {
"already_downloaded_toast" "already_downloaded_toast"
} else { } else {
"already_queued_toast" "already_queued_toast"
}].let { }].let {
runCatching { callback.onFailure(it, null) }.onFailure { fallbackToast(it) } callbackOnFailure(it, null)
} }
return@launch return@launch
} }
val pendingDownloadObject = PendingDownload( val pendingDownloadObject = PendingDownload(
metadata = downloadMetadata metadata = downloadMetadata
) ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager }
SharedContext.downloadTaskManager.addTask(pendingDownloadObject) pendingDownloadObject.also {
pendingDownloadObject.apply { remoteSideContext.downloadTaskManager.addTask(it)
}.apply {
job = coroutineContext.job job = coroutineContext.job
downloadStage = DownloadStage.DOWNLOADING downloadStage = DownloadStage.DOWNLOADING
} }
@ -344,9 +344,7 @@ class DownloadProcessor (
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension)
runCatching { runCatching {
translation.format("download_toast", "path" to media.file.nameWithoutExtension).let { callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension))
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.MERGING pendingDownloadObject.downloadStage = DownloadStage.MERGING
MediaDownloaderHelper.mergeOverlayFile( MediaDownloaderHelper.mergeOverlayFile(
@ -359,9 +357,7 @@ class DownloadProcessor (
}.onFailure { exception -> }.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(exception) Logger.error(exception)
translation.format("failed_processing_toast", "error" to exception.toString()).let { callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED
} }
@ -375,9 +371,7 @@ class DownloadProcessor (
}.onFailure { exception -> }.onFailure { exception ->
pendingDownloadObject.downloadStage = DownloadStage.FAILED pendingDownloadObject.downloadStage = DownloadStage.FAILED
Logger.error(exception) Logger.error(exception)
translation["failed_generic_toast"].let { callbackOnFailure(translation["failed_generic_toast"], exception.message)
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
} }
} }
} }

View File

@ -11,6 +11,13 @@ import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.ui.AppMaterialTheme import me.rhunk.snapenhance.ui.AppMaterialTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
lateinit var sections: Map<EnumSection, Section>
override fun onPostResume() {
super.onPostResume()
sections.values.forEach { it.onResumed() }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -20,8 +27,12 @@ class MainActivity : ComponentActivity() {
checkForRequirements() checkForRequirements()
} }
val sections = EnumSection.values().toList().associateWith { sections = EnumSection.values().toList().associateWith {
it.section.constructors.first().call() runCatching {
it.section.constructors.first().call()
}.onFailure {
it.printStackTrace()
}.getOrThrow()
}.onEach { (section, instance) -> }.onEach { (section, instance) ->
with(instance) { with(instance) {
enumSection = section enumSection = section

View File

@ -14,6 +14,7 @@ import androidx.navigation.compose.composable
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.sections.HomeSection import me.rhunk.snapenhance.ui.manager.sections.HomeSection
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented import me.rhunk.snapenhance.ui.manager.sections.NotImplemented
import me.rhunk.snapenhance.ui.manager.sections.download.DownloadSection
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -26,7 +27,8 @@ enum class EnumSection(
DOWNLOADS( DOWNLOADS(
route = "downloads", route = "downloads",
title = "Downloads", title = "Downloads",
icon = Icons.Filled.Download icon = Icons.Filled.Download,
section = DownloadSection::class
), ),
FEATURES( FEATURES(
route = "features", route = "features",
@ -66,6 +68,7 @@ open class Section {
lateinit var navController: NavController lateinit var navController: NavController
open fun init() {} open fun init() {}
open fun onResumed() {}
@Composable @Composable
open fun Content() { NotImplemented() } open fun Content() { NotImplemented() }

View File

@ -18,11 +18,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.setup.Requirements import me.rhunk.snapenhance.ui.setup.Requirements
@ -31,6 +33,7 @@ class HomeSection : Section() {
companion object { companion object {
val cardMargin = 10.dp val cardMargin = 10.dp
} }
private val installationSummary = mutableStateOf(null as InstallationSummary?)
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -72,7 +75,8 @@ class HomeSection : Section() {
"Mappings ${if (installationSummary.mappingsInfo == null) "not generated" else "outdated"}" "Mappings ${if (installationSummary.mappingsInfo == null) "not generated" else "outdated"}"
} else { } else {
"Mappings version ${installationSummary.mappingsInfo.generatedSnapchatVersion}" "Mappings version ${installationSummary.mappingsInfo.generatedSnapchatVersion}"
}, modifier = Modifier.weight(1f) }, modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
) )
@ -86,6 +90,14 @@ class HomeSection : Section() {
} }
} }
override fun onResumed() {
Logger.debug("HomeSection resumed")
if (!context.mappings.isMappingsLoaded()) {
context.mappings.init()
}
installationSummary.value = context.getInstallationSummary()
}
@Composable @Composable
@Preview @Preview
override fun Content() { override fun Content() {
@ -105,7 +117,7 @@ class HomeSection : Section() {
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
SummaryCards(context.getInstallationSummary()) SummaryCards(installationSummary = installationSummary.value ?: return)
} }
} }
} }

View File

@ -0,0 +1,11 @@
package me.rhunk.snapenhance.ui.manager.sections.download
import androidx.compose.runtime.Composable
import me.rhunk.snapenhance.ui.manager.Section
class DownloadSection : Section() {
@Composable
override fun Content() {
}
}

View File

@ -5,7 +5,6 @@ object Requirements {
const val LANGUAGE = 0b00010 const val LANGUAGE = 0b00010
const val MAPPINGS = 0b00100 const val MAPPINGS = 0b00100
const val SAVE_FOLDER = 0b01000 const val SAVE_FOLDER = 0b01000
const val FFMPEG = 0b10000
fun getName(requirement: Int): String { fun getName(requirement: Int): String {
return when (requirement) { return when (requirement) {
@ -13,7 +12,6 @@ object Requirements {
LANGUAGE -> "LANGUAGE" LANGUAGE -> "LANGUAGE"
MAPPINGS -> "MAPPINGS" MAPPINGS -> "MAPPINGS"
SAVE_FOLDER -> "SAVE_FOLDER" SAVE_FOLDER -> "SAVE_FOLDER"
FFMPEG -> "FFMPEG"
else -> "UNKNOWN" else -> "UNKNOWN"
} }
} }

View File

@ -34,7 +34,6 @@ import androidx.navigation.compose.rememberNavController
import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.ui.AppMaterialTheme import me.rhunk.snapenhance.ui.AppMaterialTheme
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.FfmpegScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen
import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen
@ -65,9 +64,6 @@ class SetupActivity : ComponentActivity() {
if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) { if (isFirstRun || hasRequirement(Requirements.MAPPINGS)) {
add(MappingsScreen().apply { route = "mappings" }) add(MappingsScreen().apply { route = "mappings" })
} }
if (isFirstRun || hasRequirement(Requirements.FFMPEG)) {
add(FfmpegScreen().apply { route = "ffmpeg" })
}
} }
// If there are no required screens, we can just finish the activity // If there are no required screens, we can just finish the activity

View File

@ -1,17 +0,0 @@
package me.rhunk.snapenhance.ui.setup.screens.impl
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
class FfmpegScreen : SetupScreen() {
@Composable
override fun Content() {
Text(text = "FFmpeg")
Button(onClick = { allowNext(true) }) {
Text(text = "Next")
}
}
}

View File

@ -40,7 +40,7 @@ class PickLanguageScreen : SetupScreen(){
fun getLocaleDisplayName(locale: String): String { fun getLocaleDisplayName(locale: String): String {
locale.split("_").let { locale.split("_").let {
return java.util.Locale(it[0], it[1]).getDisplayName(java.util.Locale.getDefault()) return Locale(it[0], it[1]).getDisplayName(Locale.getDefault())
} }
} }

View File

@ -18,51 +18,6 @@ object SharedContext {
lateinit var downloadTaskManager: DownloadTaskManager lateinit var downloadTaskManager: DownloadTaskManager
lateinit var translation: LocaleWrapper lateinit var translation: LocaleWrapper
private fun askForStoragePermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.addCategory("android.intent.category.DEFAULT")
intent.data = android.net.Uri.parse("package:${context.packageName}")
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
exitProcess(0)
}
if (context !is Activity) {
Logger.log("Storage permission not granted, exiting")
exitProcess(0)
}
context.requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE), 0)
}
private fun askForPermissions(context: Context) {
//ask for storage permission
val hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
context.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
if (hasStoragePermission) return
if (context !is Activity) {
askForStoragePermission(context)
return
}
AlertDialog.Builder(context)
.setTitle("Storage permission")
.setMessage("App needs storage permission to download files and save them to your device. Please allow it in the next screen.")
.setPositiveButton("Grant") { _, _ ->
askForStoragePermission(context)
}
.setNegativeButton("Cancel") { _, _ ->
exitProcess(0)
}
.show()
}
fun ensureInitialized(context: Context) { fun ensureInitialized(context: Context) {
if (!this::downloadTaskManager.isInitialized) { if (!this::downloadTaskManager.isInitialized) {
downloadTaskManager = DownloadTaskManager().apply { downloadTaskManager = DownloadTaskManager().apply {

View File

@ -16,9 +16,11 @@ class DownloaderConfig : ConfigContainer() {
"append_date_time", "append_date_time",
"append_type", "append_type",
"append_username" "append_username"
) ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) }
val allowDuplicate = boolean("allow_duplicate") val allowDuplicate = boolean("allow_duplicate")
val mergeOverlays = boolean("merge_overlays") val mergeOverlays = boolean("merge_overlays")
val chatDownloadContextMenu = boolean("chat_download_context_menu") val chatDownloadContextMenu = boolean("chat_download_context_menu")
val logging = multiple("logging", "started", "success", "progress", "failure") val logging = multiple("logging", "started", "success", "progress", "failure").apply {
set(mutableListOf("started", "success"))
}
} }

View File

@ -9,7 +9,7 @@ import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.DownloadRequest import me.rhunk.snapenhance.download.data.DownloadRequest
import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
import me.rhunk.snapenhance.download.enums.DownloadMediaType import me.rhunk.snapenhance.download.data.DownloadMediaType
class DownloadManagerClient ( class DownloadManagerClient (
private val context: ModContext, private val context: ModContext,

View File

@ -5,7 +5,7 @@ import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.download.data.DownloadMetadata import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadStage import me.rhunk.snapenhance.download.data.DownloadStage
import me.rhunk.snapenhance.ui.download.MediaFilter import me.rhunk.snapenhance.ui.download.MediaFilter
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
@ -158,6 +158,7 @@ class DownloadTaskManager {
iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl"))
) )
).apply { ).apply {
downloadTaskManager = this@DownloadTaskManager
downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage")))
//if downloadStage is not saved, it means the app was killed before the download was finished //if downloadStage is not saved, it means the app was killed before the download was finished
if (downloadStage != DownloadStage.SAVED) { if (downloadStage != DownloadStage.SAVED) {

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.download.enums package me.rhunk.snapenhance.download.data
import android.net.Uri import android.net.Uri

View File

@ -1,7 +1,5 @@
package me.rhunk.snapenhance.download.data package me.rhunk.snapenhance.download.data
import me.rhunk.snapenhance.download.enums.DownloadMediaType
data class DashOptions(val offsetTime: Long, val duration: Long?) data class DashOptions(val offsetTime: Long, val duration: Long?)
data class InputMedia( data class InputMedia(

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.download.enums package me.rhunk.snapenhance.download.data
enum class DownloadStage( enum class DownloadStage(
val isFinalStage: Boolean = false, val isFinalStage: Boolean = false,

View File

@ -2,15 +2,16 @@ package me.rhunk.snapenhance.download.data
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.download.enums.DownloadStage import me.rhunk.snapenhance.download.DownloadTaskManager
data class PendingDownload( data class PendingDownload(
var downloadId: Int = 0, var downloadId: Int = 0,
var outputFile: String? = null, var outputFile: String? = null,
var job: Job? = null,
val metadata : DownloadMetadata val metadata : DownloadMetadata
) { ) {
lateinit var downloadTaskManager: DownloadTaskManager
var job: Job? = null
var changeListener = { _: DownloadStage, _: DownloadStage -> } var changeListener = { _: DownloadStage, _: DownloadStage -> }
private var _stage: DownloadStage = DownloadStage.PENDING private var _stage: DownloadStage = DownloadStage.PENDING
var downloadStage: DownloadStage var downloadStage: DownloadStage
@ -20,15 +21,13 @@ data class PendingDownload(
set(value) = synchronized(this) { set(value) = synchronized(this) {
changeListener(_stage, value) changeListener(_stage, value)
_stage = value _stage = value
SharedContext.downloadTaskManager.updateTask(this) downloadTaskManager.updateTask(this)
} }
fun isJobActive(): Boolean { fun isJobActive() = job?.isActive == true
return job?.isActive ?: false
}
fun cancel() { fun cancel() {
job?.cancel()
downloadStage = DownloadStage.CANCELLED downloadStage = DownloadStage.CANCELLED
job?.cancel()
} }
} }

View File

@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.widget.ImageView import android.widget.ImageView
import com.arthenica.ffmpegkit.FFmpegKit
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
@ -20,11 +19,11 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap
import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.download.DownloadManagerClient import me.rhunk.snapenhance.download.DownloadManagerClient
import me.rhunk.snapenhance.download.data.DownloadMediaType
import me.rhunk.snapenhance.download.data.DownloadMetadata import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.SplitMediaAssetType import me.rhunk.snapenhance.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.download.data.toKeyPair import me.rhunk.snapenhance.download.data.toKeyPair
import me.rhunk.snapenhance.download.enums.DownloadMediaType
import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.Messaging
@ -52,11 +51,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null private var lastSeenMapParams: ParamMap? = null
private val isFFmpegPresent by lazy {
runCatching { FFmpegKit.execute("-version") }.isSuccess
}
private fun provideClientDownloadManager( private fun provideDownloadManagerClient(
pathSuffix: String, pathSuffix: String,
mediaIdentifier: String, mediaIdentifier: String,
mediaDisplaySource: String? = null, mediaDisplaySource: String? = null,
@ -115,10 +111,6 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
) )
} }
private fun canMergeOverlay(): Boolean {
if (!context.config.downloader.autoDownloadOptions.get().contains("merge_overlay")) return false
return isFFmpegPresent
}
//TODO: implement subfolder argument //TODO: implement subfolder argument
private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String { private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String {
@ -246,7 +238,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
val author = context.database.getFriendInfo(senderId) ?: return val author = context.database.getFriendInfo(senderId) ?: return
val authorUsername = author.usernameForSorting!! val authorUsername = author.usernameForSorting!!
downloadOperaMedia(provideClientDownloadManager( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorUsername, pathSuffix = authorUsername,
mediaIdentifier = "$conversationId$senderId${conversationMessage.server_message_id}", mediaIdentifier = "$conversationId$senderId${conversationMessage.server_message_id}",
mediaDisplaySource = authorUsername, mediaDisplaySource = authorUsername,
@ -286,7 +278,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
) ?: throw Exception("Friend not found in database") ) ?: throw Exception("Friend not found in database")
val authorName = author.usernameForSorting!! val authorName = author.usernameForSorting!!
downloadOperaMedia(provideClientDownloadManager( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorName, pathSuffix = authorName,
mediaIdentifier = paramMap["MEDIA_ID"].toString(), mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaDisplaySource = authorName, mediaDisplaySource = authorName,
@ -305,7 +297,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
"[^\\x00-\\x7F]".toRegex(), "[^\\x00-\\x7F]".toRegex(),
"") "")
downloadOperaMedia(provideClientDownloadManager( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Public-Stories/$userDisplayName", pathSuffix = "Public-Stories/$userDisplayName",
mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = userDisplayName, mediaDisplayType = userDisplayName,
@ -316,7 +308,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
//spotlight //spotlight
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) {
downloadOperaMedia(provideClientDownloadManager( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Spotlight", pathSuffix = "Spotlight",
mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType, mediaDisplayType = MediaFilter.SPOTLIGHT.mediaDisplayType,
@ -328,11 +320,6 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
//stories with mpeg dash media //stories with mpeg dash media
//TODO: option to download multiple chapters //TODO: option to download multiple chapters
if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) {
if (!isFFmpegPresent) {
context.shortToast("Can't download media. ffmpeg was not found")
return
}
val storyName = paramMap["STORY_NAME"].toString().replace( val storyName = paramMap["STORY_NAME"].toString().replace(
"[^\\x00-\\x7F]".toRegex(), "[^\\x00-\\x7F]".toRegex(),
"") "")
@ -361,7 +348,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
} }
} }
provideClientDownloadManager( provideDownloadManagerClient(
pathSuffix = "Pro-Stories/${storyName}", pathSuffix = "Pro-Stories/${storyName}",
mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}",
mediaDisplaySource = storyName, mediaDisplaySource = storyName,
@ -396,10 +383,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list") val isVideo = mediaParamMap.containsKey("video_media_info_list")
val canMergeOverlay = context.config.downloader.autoDownloadOptions.get().contains("merge_overlay")
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
) )
if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) { if (canMergeOverlay && mediaParamMap.containsKey("overlay_image_media_info")) {
mediaInfoMap[SplitMediaAssetType.OVERLAY] = mediaInfoMap[SplitMediaAssetType.OVERLAY] =
MediaInfo(mediaParamMap["overlay_image_media_info"]!!) MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
} }
@ -483,7 +472,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
runCatching { runCatching {
if (!isPreview) { if (!isPreview) {
val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage)
provideClientDownloadManager( provideDownloadManagerClient(
pathSuffix = authorName, pathSuffix = authorName,
mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.server_message_id}", mediaIdentifier = "${message.client_conversation_id}${message.sender_id}${message.server_message_id}",
mediaDisplaySource = authorName, mediaDisplaySource = authorName,

View File

@ -27,7 +27,7 @@ import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.core.R import me.rhunk.snapenhance.core.R
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadStage import me.rhunk.snapenhance.download.data.DownloadStage
import me.rhunk.snapenhance.util.snap.PreviewUtils import me.rhunk.snapenhance.util.snap.PreviewUtils
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream