mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 05:07:46 +02:00
feat(ui): setup activity
- remote side context - fix float dialogs - fix choose folder
This commit is contained in:
@ -83,11 +83,11 @@ interface BridgeInterface {
|
||||
void clearMessageLogger();
|
||||
|
||||
/**
|
||||
* Fetch the translations
|
||||
* Fetch the locales
|
||||
*
|
||||
* @return the translations result
|
||||
* @return the locale result
|
||||
*/
|
||||
Map<String, String> fetchTranslations();
|
||||
Map<String, String> fetchLocales(String userLocale);
|
||||
|
||||
/**
|
||||
* Get check for updates last time
|
||||
|
@ -1,4 +1,21 @@
|
||||
{
|
||||
"setup": {
|
||||
"dialogs": {
|
||||
"select_language": "Select Language",
|
||||
"save_folder": "For downloading snapchat media, you'll need to choose a save location. This can be changed later in the application settings.",
|
||||
"select_save_folder_button": "Select Save Folder",
|
||||
"mappings": "To support a wide range of versions, mappings need to be generated for the current snapchat version."
|
||||
},
|
||||
"mappings": {
|
||||
"dialog": "To support a wide range of versions, mappings need to be generated for the current snapchat version.",
|
||||
"snapchat_not_found": "Snapchat could not be found on your device. Please install Snapchat and try again.",
|
||||
"snapchat_not_supported": "Snapchat is not supported. Please update Snapchat and try again.",
|
||||
"generate_button": "Generate",
|
||||
"generate_error": "An error occurred while generating mappings. Please try again.",
|
||||
"generate_success": "Mappings generated successfully."
|
||||
}
|
||||
},
|
||||
|
||||
"category": {
|
||||
"spying_privacy": "Spying & Privacy",
|
||||
"media_manager": "Media Manager",
|
||||
|
@ -1,4 +1,12 @@
|
||||
{
|
||||
"setup": {
|
||||
"dialogs": {
|
||||
"select_language": "Selectionner une langue",
|
||||
"save_folder": "Pour télécharger les médias Snapchat, vous devez choisir un emplacement de sauvegarde. Cela peut être modifié plus tard dans les paramètres de l'application.",
|
||||
"select_save_folder_button": "Choisir un emplacement de sauvegarde"
|
||||
}
|
||||
},
|
||||
|
||||
"category": {
|
||||
"spying_privacy": "Espionnage et vie privée",
|
||||
"media_manager": "Gestionnaire de média",
|
||||
|
@ -16,6 +16,11 @@ object Logger {
|
||||
Log.d(TAG, message.toString())
|
||||
}
|
||||
|
||||
fun debug(tag: String, message: Any?) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
Log.d(tag, message.toString())
|
||||
}
|
||||
|
||||
fun error(throwable: Throwable) {
|
||||
Log.e(TAG, "", throwable)
|
||||
}
|
||||
|
@ -122,4 +122,8 @@ class ModContext {
|
||||
fun reloadConfig() {
|
||||
modConfig.loadFromBridge(bridgeClient)
|
||||
}
|
||||
|
||||
fun getConfigLocale(): String {
|
||||
return modConfig.locale
|
||||
}
|
||||
}
|
@ -90,14 +90,14 @@ class SnapEnhance {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private suspend fun init() {
|
||||
//load translations in a coroutine to speed up initialization
|
||||
withContext(appContext.coroutineDispatcher) {
|
||||
appContext.translation.loadFromBridge(appContext.bridgeClient)
|
||||
}
|
||||
|
||||
measureTime {
|
||||
with(appContext) {
|
||||
reloadConfig()
|
||||
withContext(appContext.coroutineDispatcher) {
|
||||
translation.userLocale = getConfigLocale()
|
||||
translation.loadFromBridge(appContext.bridgeClient)
|
||||
}
|
||||
|
||||
mappings.init()
|
||||
eventDispatcher.init()
|
||||
//if mappings aren't loaded, we can't initialize features
|
||||
|
@ -36,8 +36,9 @@ class BridgeClient(
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
)
|
||||
|
||||
//TODO: randomize package name
|
||||
val intent = Intent()
|
||||
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
|
||||
.setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.bridge.BridgeService")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bindService(
|
||||
intent,
|
||||
@ -103,7 +104,7 @@ class BridgeClient(
|
||||
|
||||
fun clearMessageLogger() = service.clearMessageLogger()
|
||||
|
||||
fun fetchTranslations() = service.fetchTranslations().map {
|
||||
fun fetchLocales(userLocale: String) = service.fetchLocales(userLocale).map {
|
||||
LocalePair(it.key, it.value)
|
||||
}
|
||||
|
||||
|
@ -1,105 +0,0 @@
|
||||
package me.rhunk.snapenhance.bridge
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import me.rhunk.snapenhance.SharedContext
|
||||
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.download.DownloadProcessor
|
||||
|
||||
class BridgeService : Service() {
|
||||
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
|
||||
return BridgeBinder()
|
||||
}
|
||||
|
||||
inner class BridgeBinder : BridgeInterface.Stub() {
|
||||
override fun createAndReadFile(fileType: Int, defaultContent: ByteArray?): ByteArray {
|
||||
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
|
||||
?: return defaultContent ?: ByteArray(0)
|
||||
|
||||
if (!file.exists()) {
|
||||
if (defaultContent == null) {
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
file.writeBytes(defaultContent)
|
||||
}
|
||||
|
||||
return file.readBytes()
|
||||
}
|
||||
|
||||
override fun readFile(fileType: Int): ByteArray {
|
||||
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
|
||||
?: return ByteArray(0)
|
||||
|
||||
if (!file.exists()) {
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
return file.readBytes()
|
||||
}
|
||||
|
||||
override fun writeFile(fileType: Int, content: ByteArray?): Boolean {
|
||||
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
|
||||
?: return false
|
||||
|
||||
if (content == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
file.writeBytes(content)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteFile(fileType: Int): Boolean {
|
||||
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
|
||||
?: return false
|
||||
|
||||
if (!file.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return file.delete()
|
||||
}
|
||||
|
||||
override fun isFileExists(fileType: Int): Boolean {
|
||||
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
|
||||
?: return false
|
||||
|
||||
return file.exists()
|
||||
}
|
||||
|
||||
override fun getLoggedMessageIds(conversationId: String, limit: Int) = messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray()
|
||||
|
||||
override fun getMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.getMessage(conversationId, id).second
|
||||
|
||||
override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) {
|
||||
messageLoggerWrapper.addMessage(conversationId, id, message)
|
||||
}
|
||||
|
||||
override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id)
|
||||
|
||||
override fun clearMessageLogger() = messageLoggerWrapper.clearMessages()
|
||||
|
||||
override fun fetchTranslations() = LocaleWrapper.fetchLocales(context = this@BridgeService).associate {
|
||||
it.locale to it.content
|
||||
}
|
||||
|
||||
override fun getAutoUpdaterTime(): Long {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun setAutoUpdaterTime(time: Long) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
|
||||
SharedContext.ensureInitialized(this@BridgeService)
|
||||
DownloadProcessor(this@BridgeService, callback).onReceive(intent)
|
||||
}
|
||||
}
|
||||
}
|
@ -13,15 +13,14 @@ class LocaleWrapper {
|
||||
companion object {
|
||||
const val DEFAULT_LOCALE = "en_US"
|
||||
|
||||
fun fetchLocales(context: Context): List<LocalePair> {
|
||||
val deviceLocale = Locale.getDefault().toString()
|
||||
val locales = mutableListOf<LocalePair>()
|
||||
fun fetchLocales(context: Context, locale: String = DEFAULT_LOCALE): List<LocalePair> {
|
||||
val locales = mutableListOf<LocalePair>().apply {
|
||||
add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() }))
|
||||
}
|
||||
|
||||
locales.add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() }))
|
||||
if (locale == DEFAULT_LOCALE) return locales
|
||||
|
||||
if (deviceLocale == DEFAULT_LOCALE) return locales
|
||||
|
||||
val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(deviceLocale) }?.substring(0, 5) ?: return locales
|
||||
val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(locale) }?.substring(0, 5) ?: return locales
|
||||
|
||||
context.resources.assets.open("lang/$compatibleLocale.json").use { inputStream ->
|
||||
locales.add(LocalePair(compatibleLocale, inputStream.bufferedReader().use { it.readText() }))
|
||||
@ -29,19 +28,24 @@ class LocaleWrapper {
|
||||
|
||||
return locales
|
||||
}
|
||||
|
||||
fun fetchAvailableLocales(context: Context): List<String> {
|
||||
return context.resources.assets.list("lang")?.map { it.substring(0, 5) } ?: listOf()
|
||||
}
|
||||
}
|
||||
|
||||
var userLocale = DEFAULT_LOCALE
|
||||
|
||||
private val translationMap = linkedMapOf<String, String>()
|
||||
private lateinit var _locale: String
|
||||
private lateinit var _loadedLocaleString: String
|
||||
|
||||
val locale by lazy {
|
||||
Locale(_locale.substring(0, 2), _locale.substring(3, 5))
|
||||
val loadedLocale by lazy {
|
||||
Locale(_loadedLocaleString.substring(0, 2), _loadedLocaleString.substring(3, 5))
|
||||
}
|
||||
|
||||
private fun load(localePair: LocalePair) {
|
||||
if (!::_locale.isInitialized) {
|
||||
_locale = localePair.locale
|
||||
if (!::_loadedLocaleString.isInitialized) {
|
||||
_loadedLocaleString = localePair.locale
|
||||
}
|
||||
|
||||
val translations = JsonParser.parseString(localePair.content).asJsonObject
|
||||
@ -64,17 +68,23 @@ class LocaleWrapper {
|
||||
}
|
||||
|
||||
fun loadFromBridge(bridgeClient: BridgeClient) {
|
||||
bridgeClient.fetchTranslations().forEach {
|
||||
bridgeClient.fetchLocales(userLocale).forEach {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFromContext(context: Context) {
|
||||
fetchLocales(context).forEach {
|
||||
fetchLocales(context, userLocale).forEach {
|
||||
load(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadFromContext(context: Context, locale: String) {
|
||||
userLocale = locale
|
||||
translationMap.clear()
|
||||
loadFromContext(context)
|
||||
}
|
||||
|
||||
operator fun get(key: String): String {
|
||||
return translationMap[key] ?: key.also { Logger.debug("Missing translation for $key") }
|
||||
}
|
||||
|
@ -46,7 +46,6 @@ class MappingsWrapper(
|
||||
private val mappings = ConcurrentHashMap<String, Any>()
|
||||
private var snapBuildNumber: Long = 0
|
||||
|
||||
@Suppress("deprecation")
|
||||
fun init() {
|
||||
snapBuildNumber = getSnapchatVersionCode()
|
||||
|
||||
@ -54,6 +53,7 @@ class MappingsWrapper(
|
||||
runCatching {
|
||||
loadCached()
|
||||
}.onFailure {
|
||||
Logger.error("Failed to load cached mappings", it)
|
||||
delete()
|
||||
}
|
||||
}
|
||||
@ -100,6 +100,7 @@ class MappingsWrapper(
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
snapBuildNumber = getSnapchatVersionCode()
|
||||
val mapper = Mapper(*mappers)
|
||||
|
||||
runCatching {
|
||||
@ -114,7 +115,7 @@ class MappingsWrapper(
|
||||
}
|
||||
write(result.toString().toByteArray())
|
||||
}.also {
|
||||
Logger.xposedLog("Generated mappings in $it ms")
|
||||
Logger.debug("Generated mappings in $it ms")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,7 +38,7 @@ open class ConfigContainer(
|
||||
vararg values: String = emptyArray(),
|
||||
params: ConfigParamsBuilder = {}
|
||||
) = registerProperty(key,
|
||||
DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(emptyList<String>(), defaultValues = values.toList()), params)
|
||||
DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(mutableListOf<String>(), defaultValues = values.toList()), params)
|
||||
|
||||
//null value is considered as Off/Disabled
|
||||
protected fun unique(
|
||||
|
@ -10,22 +10,20 @@ import me.rhunk.snapenhance.bridge.FileLoaderWrapper
|
||||
import me.rhunk.snapenhance.bridge.types.BridgeFileType
|
||||
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
|
||||
import me.rhunk.snapenhance.core.config.impl.RootConfig
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class ModConfig {
|
||||
|
||||
var locale: String = LocaleWrapper.DEFAULT_LOCALE
|
||||
set(value) {
|
||||
field = value
|
||||
writeConfig()
|
||||
}
|
||||
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
private val file = FileLoaderWrapper(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8))
|
||||
var wasPresent by Delegates.notNull<Boolean>()
|
||||
|
||||
val root = RootConfig()
|
||||
operator fun getValue(thisRef: Any?, property: Any?) = root
|
||||
|
||||
private fun load() {
|
||||
wasPresent = file.isFileExists()
|
||||
if (!file.isFileExists()) {
|
||||
writeConfig()
|
||||
return
|
||||
@ -42,12 +40,13 @@ class ModConfig {
|
||||
private fun loadConfig() {
|
||||
val configFileContent = file.read()
|
||||
val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java)
|
||||
locale = configObject.get("language")?.asString ?: LocaleWrapper.DEFAULT_LOCALE
|
||||
locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE
|
||||
root.fromJson(configObject)
|
||||
}
|
||||
|
||||
fun writeConfig() {
|
||||
val configObject = root.toJson()
|
||||
configObject.addProperty("language", locale)
|
||||
configObject.addProperty("_locale", locale)
|
||||
file.write(configObject.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
|
@ -16,11 +16,16 @@ class DownloadManagerClient (
|
||||
private val metadata: DownloadMetadata,
|
||||
private val callback: DownloadCallback
|
||||
) {
|
||||
companion object {
|
||||
const val DOWNLOAD_REQUEST_EXTRA = "request"
|
||||
const val DOWNLOAD_METADATA_EXTRA = "metadata"
|
||||
}
|
||||
|
||||
private fun enqueueDownloadRequest(request: DownloadRequest) {
|
||||
context.bridgeClient.enqueueDownload(Intent().apply {
|
||||
putExtras(Bundle().apply {
|
||||
putString(DownloadProcessor.DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request))
|
||||
putString(DownloadProcessor.DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata))
|
||||
putString(DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request))
|
||||
putString(DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata))
|
||||
})
|
||||
}, callback)
|
||||
}
|
||||
|
@ -1,386 +0,0 @@
|
||||
package me.rhunk.snapenhance.download
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.google.gson.GsonBuilder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.Logger
|
||||
import me.rhunk.snapenhance.SharedContext
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.core.config.ModConfig
|
||||
import me.rhunk.snapenhance.data.FileType
|
||||
import me.rhunk.snapenhance.download.data.DownloadMetadata
|
||||
import me.rhunk.snapenhance.download.data.DownloadRequest
|
||||
import me.rhunk.snapenhance.download.data.InputMedia
|
||||
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
|
||||
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.snap.MediaDownloaderHelper
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.transform.TransformerFactory
|
||||
import javax.xml.transform.dom.DOMSource
|
||||
import javax.xml.transform.stream.StreamResult
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class DownloadedFile(
|
||||
val file: File,
|
||||
val fileType: FileType
|
||||
)
|
||||
|
||||
/**
|
||||
* DownloadProcessor handles the download requests of the user
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class DownloadProcessor (
|
||||
private val context: Context,
|
||||
private val callback: DownloadCallback
|
||||
) {
|
||||
companion object {
|
||||
const val DOWNLOAD_REQUEST_EXTRA = "request"
|
||||
const val DOWNLOAD_METADATA_EXTRA = "metadata"
|
||||
}
|
||||
|
||||
private val translation by lazy {
|
||||
SharedContext.translation.getCategory("download_processor")
|
||||
}
|
||||
|
||||
private val gson by lazy {
|
||||
GsonBuilder().setPrettyPrinting().create()
|
||||
}
|
||||
|
||||
private fun fallbackToast(message: Any) {
|
||||
android.os.Handler(context.mainLooper).post {
|
||||
Toast.makeText(context, message.toString(), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractZip(inputStream: InputStream): List<File> {
|
||||
val files = mutableListOf<File>()
|
||||
val zipInputStream = ZipInputStream(inputStream)
|
||||
var entry = zipInputStream.nextEntry
|
||||
|
||||
while (entry != null) {
|
||||
createMediaTempFile().also { file ->
|
||||
file.outputStream().use { outputStream ->
|
||||
zipInputStream.copyTo(outputStream)
|
||||
}
|
||||
files += file
|
||||
}
|
||||
entry = zipInputStream.nextEntry
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private fun decryptInputStream(inputStream: InputStream, encryption: MediaEncryptionKeyPair): InputStream {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val key = Base64.UrlSafe.decode(encryption.key)
|
||||
val iv = Base64.UrlSafe.decode(encryption.iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
|
||||
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")
|
||||
private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) {
|
||||
if (coroutineContext.job.isCancelled) return
|
||||
|
||||
val config by ModConfig().apply { loadFromContext(context) }
|
||||
|
||||
runCatching {
|
||||
val fileType = FileType.fromFile(inputFile)
|
||||
if (fileType == FileType.UNKNOWN) {
|
||||
callback.onFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null)
|
||||
return
|
||||
}
|
||||
|
||||
val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
|
||||
|
||||
val outputFolder = DocumentFile.fromTreeUri(context, Uri.parse(config.downloader.saveFolder.get()))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = pendingDownload.metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name) ?: folder.createDirectory(name)!!
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
|
||||
val outputStream = context.contentResolver.openOutputStream(outputFile.uri)!!
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
|
||||
pendingDownload.outputFile = outputFile.uri.toString()
|
||||
pendingDownload.downloadStage = DownloadStage.SAVED
|
||||
|
||||
runCatching {
|
||||
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE")
|
||||
mediaScanIntent.setData(outputFile.uri)
|
||||
context.sendBroadcast(mediaScanIntent)
|
||||
}.onFailure {
|
||||
Logger.error("Failed to scan media file", it)
|
||||
callback.onFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message)
|
||||
}
|
||||
|
||||
Logger.debug("download complete")
|
||||
fileName.let {
|
||||
runCatching { callback.onSuccess(it) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
}.onFailure { exception ->
|
||||
Logger.error(exception)
|
||||
translation.format("failed_gallery_toast", "error" to exception.toString()).let {
|
||||
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
pendingDownload.downloadStage = DownloadStage.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMediaTempFile(): File {
|
||||
return File.createTempFile("media", ".tmp")
|
||||
}
|
||||
|
||||
private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking {
|
||||
val jobs = mutableListOf<Job>()
|
||||
val downloadedMedias = mutableMapOf<InputMedia, File>()
|
||||
|
||||
downloadRequest.inputMedias.forEach { inputMedia ->
|
||||
fun handleInputStream(inputStream: InputStream) {
|
||||
createMediaTempFile().apply {
|
||||
if (inputMedia.encryption != null) {
|
||||
decryptInputStream(inputStream, inputMedia.encryption).use { decryptedInputStream ->
|
||||
decryptedInputStream.copyTo(outputStream())
|
||||
}
|
||||
} else {
|
||||
inputStream.copyTo(outputStream())
|
||||
}
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
|
||||
launch {
|
||||
when (inputMedia.type) {
|
||||
DownloadMediaType.PROTO_MEDIA -> {
|
||||
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content))?.let { inputStream ->
|
||||
handleInputStream(inputStream)
|
||||
}
|
||||
}
|
||||
DownloadMediaType.DIRECT_MEDIA -> {
|
||||
val decoded = Base64.UrlSafe.decode(inputMedia.content)
|
||||
createMediaTempFile().apply {
|
||||
writeBytes(decoded)
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
DownloadMediaType.REMOTE_MEDIA -> {
|
||||
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connect()
|
||||
handleInputStream(inputStream)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
downloadedMedias[inputMedia] = File(inputMedia.content)
|
||||
}
|
||||
}
|
||||
}.also { jobs.add(it) }
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
downloadedMedias
|
||||
}
|
||||
|
||||
private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
|
||||
downloadRequest.inputMedias.first().let { inputMedia ->
|
||||
val mediaType = inputMedia.type
|
||||
val media = downloadedMedias[inputMedia]!!
|
||||
|
||||
if (!downloadRequest.isDashPlaylist) {
|
||||
saveMediaToGallery(media.file, pendingDownloadObject)
|
||||
media.file.delete()
|
||||
return
|
||||
}
|
||||
|
||||
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
|
||||
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
|
||||
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
|
||||
for (i in 0 until baseUrlNodeList.length) {
|
||||
val baseUrlNode = baseUrlNodeList.item(i)
|
||||
val baseUrl = baseUrlNode.textContent
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
}
|
||||
|
||||
val dashOptions = downloadRequest.dashOptions!!
|
||||
|
||||
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
|
||||
val xmlData = dashPlaylistFile.outputStream()
|
||||
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData))
|
||||
|
||||
translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension).let {
|
||||
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
val outputFile = File.createTempFile("dash", ".mp4")
|
||||
runCatching {
|
||||
MediaDownloaderHelper.downloadDashChapterFile(
|
||||
dashPlaylist = dashPlaylistFile,
|
||||
output = outputFile,
|
||||
startTime = dashOptions.offsetTime,
|
||||
duration = dashOptions.duration)
|
||||
saveMediaToGallery(outputFile, pendingDownloadObject)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
Logger.error(exception)
|
||||
translation.format("failed_processing_toast", "error" to exception.toString()).let {
|
||||
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
pendingDownloadObject.downloadStage = DownloadStage.FAILED
|
||||
}
|
||||
|
||||
dashPlaylistFile.delete()
|
||||
outputFile.delete()
|
||||
media.file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameFromFileType(file: File, fileType: FileType): File {
|
||||
val newFile = File(file.parentFile, file.nameWithoutExtension + "." + fileType.fileExtension)
|
||||
file.renameTo(newFile)
|
||||
return newFile
|
||||
}
|
||||
|
||||
fun onReceive(intent: Intent) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val downloadMetadata = gson.fromJson(intent.getStringExtra(DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
|
||||
val downloadRequest = gson.fromJson(intent.getStringExtra(DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
|
||||
|
||||
SharedContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage ->
|
||||
translation[if (downloadStage.isFinalStage) {
|
||||
"already_downloaded_toast"
|
||||
} else {
|
||||
"already_queued_toast"
|
||||
}].let {
|
||||
runCatching { callback.onFailure(it, null) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val pendingDownloadObject = PendingDownload(
|
||||
metadata = downloadMetadata
|
||||
)
|
||||
|
||||
SharedContext.downloadTaskManager.addTask(pendingDownloadObject)
|
||||
pendingDownloadObject.apply {
|
||||
job = coroutineContext.job
|
||||
downloadStage = DownloadStage.DOWNLOADING
|
||||
}
|
||||
|
||||
runCatching {
|
||||
//first download all input medias into cache
|
||||
val downloadedMedias = downloadInputMedias(downloadRequest).map {
|
||||
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
|
||||
}.toMap().toMutableMap()
|
||||
Logger.debug("downloaded ${downloadedMedias.size} medias")
|
||||
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { entry ->
|
||||
val extractedMedias = extractZip(entry.file.inputStream()).map {
|
||||
InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath
|
||||
) to DownloadedFile(it, FileType.fromFile(it))
|
||||
}
|
||||
|
||||
downloadedMedias.values.removeIf {
|
||||
it.file.delete()
|
||||
true
|
||||
}
|
||||
|
||||
downloadedMedias.putAll(extractedMedias)
|
||||
shouldMergeOverlay = true
|
||||
}
|
||||
|
||||
if (shouldMergeOverlay) {
|
||||
assert(downloadedMedias.size == 2)
|
||||
val media = downloadedMedias.values.first { it.fileType.isVideo }
|
||||
val overlayMedia = downloadedMedias.values.first { it.fileType.isImage }
|
||||
|
||||
val renamedMedia = renameFromFileType(media.file, media.fileType)
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
|
||||
val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension)
|
||||
runCatching {
|
||||
translation.format("download_toast", "path" to media.file.nameWithoutExtension).let {
|
||||
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
pendingDownloadObject.downloadStage = DownloadStage.MERGING
|
||||
|
||||
MediaDownloaderHelper.mergeOverlayFile(
|
||||
media = renamedMedia,
|
||||
overlay = renamedOverlayMedia,
|
||||
output = mergedOverlay
|
||||
)
|
||||
|
||||
saveMediaToGallery(mergedOverlay, pendingDownloadObject)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
Logger.error(exception)
|
||||
translation.format("failed_processing_toast", "error" to exception.toString()).let {
|
||||
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED
|
||||
}
|
||||
|
||||
mergedOverlay.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}
|
||||
|
||||
downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest)
|
||||
}.onFailure { exception ->
|
||||
pendingDownloadObject.downloadStage = DownloadStage.FAILED
|
||||
Logger.error(exception)
|
||||
translation["failed_generic_toast"].let {
|
||||
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -3,9 +3,9 @@ package me.rhunk.snapenhance.features
|
||||
object FeatureLoadParams {
|
||||
const val NO_INIT = 0
|
||||
|
||||
const val INIT_SYNC = 1
|
||||
const val ACTIVITY_CREATE_SYNC = 2
|
||||
const val INIT_SYNC = 0b0001
|
||||
const val ACTIVITY_CREATE_SYNC = 0b0010
|
||||
|
||||
const val INIT_ASYNC = 3
|
||||
const val ACTIVITY_CREATE_ASYNC = 4
|
||||
const val INIT_ASYNC = 0b0100
|
||||
const val ACTIVITY_CREATE_ASYNC = 0b1000
|
||||
}
|
@ -84,7 +84,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
|
||||
${birthday.getDisplayName(
|
||||
Calendar.MONTH,
|
||||
Calendar.LONG,
|
||||
context.translation.locale
|
||||
context.translation.loadedLocale
|
||||
)?.let {
|
||||
context.translation.format("profile_info.birthday",
|
||||
"month" to it,
|
||||
|
@ -47,9 +47,8 @@ class SettingsGearInjector : AbstractMenu() {
|
||||
|
||||
setOnClickListener {
|
||||
val intent = Intent().apply {
|
||||
setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.manager.MainActivity")
|
||||
setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity")
|
||||
putExtra("route", "features")
|
||||
putExtra("lspatched", File(context.cacheDir, "lspatch/origin").exists())
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
Reference in New Issue
Block a user