commit cc59f9d060b685eda27ccb013673dc016270a906 parent 65cbf79dce99d689fcbe0244045d559353450b24 Author: auth <64337177+authorisation@users.noreply.github.com> Date: Wed, 21 Jun 2023 19:41:36 +0200 refactor: translations (#78) * ui changes * fix more stuff * fix: set ffmpeg preset to ultrafast * refactor: ui tweaks getIdentifier function * feat: download manager filter * add placeholder bitmoji * change bitmoji blank color * add settings page (not done) * setting page impl * confirmation dialog * update theme * remove snap context buttons * fix: delete cache * fix: bitmoji icon * fix: french translation * translation refactor * translation: download manager * refactor: get operator * refactor: config properties * add translation for download manager receiver * fix: steak expire translation format --------- Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com> Diffstat:
35 files changed, 821 insertions(+), 713 deletions(-)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -34,10 +34,10 @@ tools:ignore="ExportedService"> </service> - <receiver android:name=".download.MediaDownloadReceiver" android:exported="true" + <receiver android:name=".download.DownloadManagerReceiver" android:exported="true" tools:ignore="ExportedReceiver"> <intent-filter> - <action android:name="me.rhunk.snapenhance.download.MediaDownloadReceiver.DOWNLOAD_ACTION" /> + <action android:name="me.rhunk.snapenhance.download.DownloadManagerReceiver.DOWNLOAD_ACTION" /> </intent-filter> </receiver> diff --git a/app/src/main/assets/lang/en_US.json b/app/src/main/assets/lang/en_US.json @@ -161,7 +161,7 @@ }, "conversation_preview": { - "streak_expiration": "expires in %s days %s hours %s minutes", + "streak_expiration": "expires in {day} days {hour} hours {minute} minutes", "title": "Preview", "unknown_user": "Unknown User" }, @@ -199,5 +199,44 @@ "finished": "Done! You now can close this dialog.", "no_messages_found": "No messages found!", "exporting_message": "Exporting {conversation}..." + }, + + "download_manager_activity": { + "remove_all_title": "Remove all Downloads", + "remove_all_text": "Are you sure you want to do this?", + "remove_all": "Remove All", + "no_downloads": "No downloads", + "cancel": "Cancel", + "file_not_found_toast": "File does not exist!", + "category": { + "all_category": "All", + "pending_category": "Pending", + "snap_category": "Snaps", + "story_category": "Stories", + "spotlight_category": "Spotlight" + }, + "settings": "Settings", + "button": { + "positive": "Yes", + "negative": "No", + "cancel": "Cancel", + "open": "Open" + }, + "settings_page": { + "clear_file_title": "Clear {file_name} file", + "clear_file_confirmation": "Are you sure you want to clear the {file_name} file?", + "clear_cache_title": "Clear Cache", + "reset_all_title": "Reset all settings", + "reset_all_confirmation": "Are you sure you want to reset all settings?", + "success_toast": "Success!" + } + }, + "download_manager_receiver": { + "saved_toast": "Saved to {path}", + "download_toast": "Downloading {path}...", + "processing_toast": "Processing {path}...", + "failed_generic_toast": "Failed to download", + "failed_processing_toast": "Failed to process {error}", + "failed_gallery_toast": "Failed to save to gallery {error}" } } \ No newline at end of file diff --git a/app/src/main/assets/lang/fr_FR.json b/app/src/main/assets/lang/fr_FR.json @@ -159,7 +159,7 @@ "username": "Nom d'utilisateur", "display_name": "Nom d'affichage", "added_date": "Date d'ajout", - "birthday": "Anniversaire : {month} {day}" + "birthday": "Anniversaire : {day} {month}" }, "auto_updater": { "no_update_available": "Aucune mise à jour disponible !", diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -10,7 +10,9 @@ import android.os.Process import android.widget.Toast import com.google.gson.Gson import com.google.gson.GsonBuilder +import kotlinx.coroutines.asCoroutineDispatcher import me.rhunk.snapenhance.bridge.AbstractBridgeClient +import me.rhunk.snapenhance.bridge.TranslationWrapper import me.rhunk.snapenhance.data.MessageSender import me.rhunk.snapenhance.database.DatabaseAccess import me.rhunk.snapenhance.features.Feature @@ -18,7 +20,6 @@ import me.rhunk.snapenhance.manager.impl.ActionManager import me.rhunk.snapenhance.manager.impl.ConfigManager import me.rhunk.snapenhance.manager.impl.FeatureManager import me.rhunk.snapenhance.manager.impl.MappingManager -import me.rhunk.snapenhance.manager.impl.TranslationManager import me.rhunk.snapenhance.util.download.DownloadServer import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -28,13 +29,17 @@ import kotlin.system.exitProcess class ModContext { private val executorService: ExecutorService = Executors.newCachedThreadPool() + val coroutineDispatcher by lazy { + executorService.asCoroutineDispatcher() + } + lateinit var androidContext: Context var mainActivity: Activity? = null lateinit var bridgeClient: AbstractBridgeClient val gson: Gson = GsonBuilder().create() - val translation = TranslationManager(this) + val translation = TranslationWrapper() val features = FeatureManager(this) val mappings = MappingManager(this) val config = ConfigManager(this) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance + +import android.content.Context +import me.rhunk.snapenhance.bridge.TranslationWrapper +import me.rhunk.snapenhance.download.DownloadTaskManager + +/** + * Used to store objects between activities and receivers + */ +object SharedContext { + lateinit var downloadTaskManager: DownloadTaskManager + lateinit var translation: TranslationWrapper + + fun ensureInitialized(context: Context) { + if (!this::downloadTaskManager.isInitialized) { + downloadTaskManager = DownloadTaskManager().apply { + init(context) + } + } + if (!this::translation.isInitialized) { + translation = TranslationWrapper().apply { + loadFromContext(context) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -5,6 +5,8 @@ import android.app.Activity import android.app.Application import android.content.Context import android.os.Build +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import me.rhunk.snapenhance.bridge.AbstractBridgeClient import me.rhunk.snapenhance.bridge.client.RootBridgeClient import me.rhunk.snapenhance.bridge.client.ServiceBridgeClient @@ -39,7 +41,9 @@ class SnapEnhance { return@start } runCatching { - init() + runBlocking { + init() + } }.onFailure { Logger.xposedLog("Failed to initialize", it) } @@ -67,10 +71,14 @@ class SnapEnhance { } @OptIn(ExperimentalTime::class) - private fun init() { + private suspend fun init() { + //load translations in a coroutine to speed up initialization + withContext(appContext.coroutineDispatcher) { + appContext.translation.loadFromBridge(appContext.bridgeClient) + } + measureTime { with(appContext) { - translation.init() config.init() mappings.init() //if mappings aren't loaded, we can't initialize features @@ -82,10 +90,15 @@ class SnapEnhance { } } + @OptIn(ExperimentalTime::class) private fun onActivityCreate() { - with(appContext) { - features.onActivityCreate() - actionManager.init() + measureTime { + with(appContext) { + features.onActivityCreate() + actionManager.init() + } + }.also { time -> + Logger.debug("onActivityCreate in $time") } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt @@ -10,7 +10,7 @@ class CheckForUpdates : AbstractAction("action.check_for_updates", dependsOnProp runCatching { val latestVersion = context.feature(AutoUpdater::class).checkForUpdates() if (latestVersion == null) { - context.longToast(context.translation.get("auto_updater.no_update_available")) + context.longToast(context.translation["auto_updater.no_update_available"]) } }.onFailure { context.longToast(it.message ?: "Failed to check for updates") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -62,7 +62,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { private suspend fun askExportType() = suspendCancellableCoroutine { cont -> context.runOnUiThread { AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("chat_export.select_export_format")) + .setTitle(context.translation["chat_export.select_export_format"]) .setItems(ExportFormat.values().map { it.name }.toTypedArray()) { _, which -> cont.resumeWith(Result.success(ExportFormat.values()[which])) } @@ -83,7 +83,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { ContentType.STICKER ) AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("chat_export.select_media_type")) + .setTitle(context.translation["chat_export.select_media_type"]) .setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked -> val media = contentTypes[which] if (isChecked) { @@ -111,7 +111,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { val selectedConversations = mutableListOf<FriendFeedInfo>() AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("chat_export.select_conversation")) + .setTitle(context.translation["chat_export.select_conversation"]) .setMultiChoiceItems( friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayName!!.split("|").firstOrNull() }.toTypedArray(), BooleanArray(friendFeedEntries.size) { false } @@ -122,13 +122,13 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { selectedConversations.remove(friendFeedEntries[which]) } } - .setNegativeButton(context.translation.get("chat_export.dialog_negative_button")) { dialog, _ -> + .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> dialog.dismiss() } - .setNeutralButton(context.translation.get("chat_export.dialog_neutral_button")) { _, _ -> + .setNeutralButton(context.translation["chat_export.dialog_neutral_button"]) { _, _ -> exportChatForConversations(friendFeedEntries) } - .setPositiveButton(context.translation.get("chat_export.dialog_positive_button")) { _, _ -> + .setPositiveButton(context.translation["chat_export.dialog_positive_button"]) { _, _ -> exportChatForConversations(selectedConversations) } .show() @@ -188,11 +188,11 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { conversationAction(true, conversationId, if (friendFeedInfo.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE") - logDialog(context.translation.get("chat_export.exporting_message").replace("{conversation}", conversationName)) + logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName)) val foundMessages = fetchMessagesPaginated(conversationId, Long.MAX_VALUE).toMutableList() var lastMessageId = foundMessages.firstOrNull()?.messageDescriptor?.messageId ?: run { - logDialog(context.translation.get("chat_export.no_messages_found")) + logDialog(context.translation["chat_export.no_messages_found"]) return } @@ -211,7 +211,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { "SnapEnhance/conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}" ).also { it.parentFile?.mkdirs() } - logDialog(context.translation.get("chat_export.writing_output")) + logDialog(context.translation["chat_export.writing_output"]) MessageExporter( context = context, friendFeedInfo = friendFeedInfo, @@ -222,14 +222,16 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { runCatching { it.readMessages(foundMessages) }.onFailure { - logDialog(context.translation.get("chat_export.export_failed").replace("{conversation}", it.message.toString())) + logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) Logger.error(it) return } }.exportTo(exportType!!) dialogLogs.clear() - logDialog("\n" + context.translation.get("chat_export.exported_to").replace("{path}", outputFile.absolutePath.toString()) + "\n") + logDialog("\n" + context.translation.format("chat_export.exported_to", + "path" to outputFile.absolutePath.toString() + ) + "\n") currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, "Open") { _, _ -> val intent = Intent(Intent.ACTION_VIEW) @@ -247,16 +249,16 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { val jobs = mutableListOf<Job>() currentActionDialog = AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("chat_export.exporting_chats")) + .setTitle(context.translation["chat_export.exporting_chats"]) .setCancelable(false) .setMessage("") - .setNegativeButton(context.translation.get("chat_export.dialog_negative_button")) { dialog, _ -> + .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> jobs.forEach { it.cancel() } dialog.dismiss() } .create() - val conversationSize = context.translation.get("chat_export.processing_chats").replace("{amount}", conversations.size.toString()) + val conversationSize = context.translation.format("chat_export.processing_chats", "amount" to conversations.size.toString()) logDialog(conversationSize) @@ -268,14 +270,14 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { runCatching { exportFullConversation(conversation) }.onFailure { - logDialog(context.translation.get("chat_export.export_fail").replace("{conversation}", conversation.key.toString())) + logDialog(context.translation.format("chat_export.export_fail", "conversation" to conversation.key.toString())) logDialog(it.stackTraceToString()) Logger.xposedLog(it) } }.also { jobs.add(it) } } jobs.joinAll() - logDialog(context.translation.get("chat_export.finished")) + logDialog(context.translation["chat_export.finished"]) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/TranslationWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/TranslationWrapper.kt @@ -0,0 +1,96 @@ +package me.rhunk.snapenhance.bridge + +import android.content.Context +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.data.LocalePair +import java.util.Locale + + +class TranslationWrapper { + companion object { + private const val DEFAULT_LOCALE = "en_US" + + fun fetchLocales(context: Context): List<LocalePair> { + val deviceLocale = Locale.getDefault().toString() + val locales = mutableListOf<LocalePair>() + + locales.add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() })) + + if (deviceLocale == DEFAULT_LOCALE) return locales + + val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(deviceLocale) }?.substring(0, 5) ?: return locales + + context.resources.assets.open("lang/$compatibleLocale.json").use { inputStream -> + locales.add(LocalePair(compatibleLocale, inputStream.bufferedReader().use { it.readText() })) + } + + return locales + } + } + + + private val translationMap = linkedMapOf<String, String>() + private lateinit var _locale: String + + val locale by lazy { + Locale(_locale.substring(0, 2), _locale.substring(3, 5)) + } + + private fun load(localePair: LocalePair) { + if (!::_locale.isInitialized) { + _locale = localePair.locale + } + + val translations = JsonParser.parseString(localePair.content).asJsonObject + if (translations == null || translations.isJsonNull) { + return + } + + fun scanObject(jsonObject: JsonObject, prefix: String = "") { + jsonObject.entrySet().forEach { + if (it.value.isJsonPrimitive) { + val key = "$prefix${it.key}" + translationMap[key] = it.value.asString + } + if (!it.value.isJsonObject) return@forEach + scanObject(it.value.asJsonObject, "$prefix${it.key}.") + } + } + + scanObject(translations) + } + + fun loadFromBridge(bridgeClient: AbstractBridgeClient) { + bridgeClient.fetchTranslations().getLocales().forEach { + load(it) + } + } + + fun loadFromContext(context: Context) { + fetchLocales(context).forEach { + load(it) + } + } + + operator fun get(key: String): String { + return translationMap[key] ?: key.also { Logger.debug("Missing translation for $key") } + } + + fun format(key: String, vararg args: Pair<String, String>): String { + return args.fold(get(key)) { acc, pair -> + acc.replace("{${pair.first}}", pair.second) + } + } + + fun getCategory(key: String): TranslationWrapper { + return TranslationWrapper().apply { + translationMap.putAll( + this@TranslationWrapper.translationMap + .filterKeys { it.startsWith("$key.") } + .mapKeys { it.key.substring(key.length + 1) } + ) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/RootBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/RootBridgeClient.kt @@ -110,7 +110,7 @@ class RootBridgeClient : AbstractBridgeClient() { if (langJsonData != null) { Logger.debug("Fetched translations for $locale") - return LocaleResult(locale, langJsonData) + return LocaleResult(arrayOf(locale), arrayOf(langJsonData.toString(Charsets.UTF_8))) } throw Throwable("Failed to fetch translations for $locale") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleResult.kt @@ -2,18 +2,30 @@ package me.rhunk.snapenhance.bridge.common.impl.locale import android.os.Bundle import me.rhunk.snapenhance.bridge.common.BridgeMessage +import me.rhunk.snapenhance.data.LocalePair class LocaleResult( - var locale: String? = null, - var content: ByteArray? = null + private var locales: Array<String>? = null, + private var localContentArray: Array<String>? = null ) : BridgeMessage(){ + + fun getLocales(): List<LocalePair> { + val locales = locales ?: return emptyList() + val localContentArray = localContentArray ?: return emptyList() + return locales.mapIndexed { index, locale -> + LocalePair(locale, localContentArray[index]) + }.asReversed() + } + + override fun write(bundle: Bundle) { - bundle.putString("locale", locale) - bundle.putByteArray("content", content) + bundle.putStringArray("locales", locales) + bundle.putSerializable("localContentArray", localContentArray) } + @Suppress("UNCHECKED_CAST", "DEPRECATION") override fun read(bundle: Bundle) { - locale = bundle.getString("locale") - content = bundle.getByteArray("content") + locales = bundle.getStringArray("locales") + localContentArray = bundle.getSerializable("localContentArray") as? Array<String> } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.* import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.bridge.MessageLoggerWrapper +import me.rhunk.snapenhance.bridge.TranslationWrapper import me.rhunk.snapenhance.bridge.common.BridgeMessageType import me.rhunk.snapenhance.bridge.common.impl.* import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest @@ -111,12 +112,15 @@ class BridgeService : Service() { } private fun handleLocaleRequest(reply: (Message) -> Unit) { - val deviceLocale = Locale.getDefault().toString() - val compatibleLocale = resources.assets.list("lang")?.find { it.startsWith(deviceLocale) }?.substring(0, 5) ?: "en_US" + val locales = sortedSetOf<String>() + val contentArray = sortedSetOf<String>() - resources.assets.open("lang/$compatibleLocale.json").use { inputStream -> - reply(LocaleResult(compatibleLocale, inputStream.readBytes()).toMessage(BridgeMessageType.LOCALE_RESULT.value)) + TranslationWrapper.fetchLocales(context = this).forEach { pair -> + locales.add(pair.locale) + contentArray.add(pair.content) } + + reply(LocaleResult(locales.toTypedArray(), contentArray.toTypedArray()).toMessage(BridgeMessageType.LOCALE_RESULT.value)) } @SuppressLint("UnspecifiedRegisterReceiverFlag") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.bridge.service - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import me.rhunk.snapenhance.Constants - -class MainActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)?.apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(this) - } - finish() - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt @@ -10,8 +10,7 @@ import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import java.io.File enum class ConfigProperty( - val nameKey: String, - val descriptionKey: String, + val translationKey: String, val category: ConfigCategory, val valueContainer: ConfigValue<*>, val shouldAppearInSettings: Boolean = true, @@ -19,26 +18,22 @@ enum class ConfigProperty( ) { //SPYING AND PRIVACY - MESSAGE_LOGGER("property.message_logger", - "description.message_logger", + MESSAGE_LOGGER("message_logger", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), PREVENT_READ_RECEIPTS( - "property.prevent_read_receipts", - "description.prevent_read_receipts", + "prevent_read_receipts", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), HIDE_BITMOJI_PRESENCE( - "property.hide_bitmoji_presence", - "description.hide_bitmoji_presence", + "hide_bitmoji_presence", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), BETTER_NOTIFICATIONS( - "property.better_notifications", - "description.better_notifications", + "better_notifications", ConfigCategory.SPYING_PRIVACY, ConfigStateListValue( listOf("snap", "chat", "reply_button"), @@ -50,8 +45,7 @@ enum class ConfigProperty( ) ), NOTIFICATION_BLACKLIST( - "property.notification_blacklist", - "description.notification_blacklist", + "notification_blacklist", ConfigCategory.SPYING_PRIVACY, ConfigStateListValue( listOf("snap", "chat", "typing"), @@ -62,56 +56,51 @@ enum class ConfigProperty( ) ) ), - DISABLE_METRICS("property.disable_metrics", - "description.disable_metrics", + DISABLE_METRICS("disable_metrics", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), - BLOCK_ADS("property.block_ads", - "description.block_ads", + BLOCK_ADS("block_ads", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), - UNLIMITED_SNAP_VIEW_TIME("property.unlimited_snap_view_time", - "description.unlimited_snap_view_time", + UNLIMITED_SNAP_VIEW_TIME("unlimited_snap_view_time", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), PREVENT_SCREENSHOT_NOTIFICATIONS( - "property.prevent_screenshot_notifications", - "description.prevent_screenshot_notifications", + "prevent_screenshot_notifications", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), PREVENT_STATUS_NOTIFICATIONS( - "property.prevent_status_notifications", - "description.prevent_status_notifications", + "prevent_status_notifications", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), ANONYMOUS_STORY_VIEW( - "property.anonymous_story_view", - "description.anonymous_story_view", + "anonymous_story_view", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), HIDE_TYPING_NOTIFICATION( - "property.hide_typing_notification", - "description.hide_typing_notification", + "hide_typing_notification", ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), //MEDIA MANAGEMENT SAVE_FOLDER( - "property.save_folder", "description.save_folder", ConfigCategory.MEDIA_MANAGEMENT, + "save_folder", + ConfigCategory.MEDIA_MANAGEMENT, ConfigStringValue(File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat", "SnapEnhance" ).absolutePath) ), AUTO_DOWNLOAD_OPTIONS( - "property.auto_download_options", "description.auto_download_options", ConfigCategory.MEDIA_MANAGEMENT, + "auto_download_options", + ConfigCategory.MEDIA_MANAGEMENT, ConfigStateListValue( listOf("friend_snaps", "friend_stories", "public_stories", "spotlight"), mutableMapOf( @@ -123,7 +112,8 @@ enum class ConfigProperty( ) ), DOWNLOAD_OPTIONS( - "property.download_options", "description.download_options", ConfigCategory.MEDIA_MANAGEMENT, + "download_options", + ConfigCategory.MEDIA_MANAGEMENT, ConfigStateListValue( listOf("format_user_folder", "format_hash", "format_date_time", "format_username", "merge_overlay"), mutableMapOf( @@ -136,22 +126,19 @@ enum class ConfigProperty( ) ), CHAT_DOWNLOAD_CONTEXT_MENU( - "property.chat_download_context_menu", - "description.chat_download_context_menu", + "chat_download_context_menu", ConfigCategory.MEDIA_MANAGEMENT, ConfigStateValue(false) ), GALLERY_MEDIA_SEND_OVERRIDE( - "property.gallery_media_send_override", - "description.gallery_media_send_override", + "gallery_media_send_override", ConfigCategory.MEDIA_MANAGEMENT, ConfigStateSelection( listOf("OFF", "NOTE", "SNAP", "LIVE_SNAP"), "OFF" ) ), - AUTO_SAVE_MESSAGES("property.auto_save_messages", - "description.auto_save_messages", + AUTO_SAVE_MESSAGES("auto_save_messages", ConfigCategory.MEDIA_MANAGEMENT, ConfigStateListValue( listOf("CHAT", "SNAP", "NOTE", "EXTERNAL_MEDIA", "STICKER") @@ -159,22 +146,19 @@ enum class ConfigProperty( ), FORCE_MEDIA_SOURCE_QUALITY( - "property.force_media_source_quality", - "description.force_media_source_quality", + "force_media_source_quality", ConfigCategory.MEDIA_MANAGEMENT, ConfigStateValue(false) ), //UI AND TWEAKS ENABLE_FRIEND_FEED_MENU_BAR( - "property.enable_friend_feed_menu_bar", - "description.enable_friend_feed_menu_bar", + "enable_friend_feed_menu_bar", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), FRIEND_FEED_MENU_BUTTONS( - "property.friend_feed_menu_buttons", - "description.friend_feed_menu_buttons", + "friend_feed_menu_buttons", ConfigCategory.UI_TWEAKS, ConfigStateListValue( listOf("auto_download_blacklist", "anti_auto_save", "stealth_mode", "conversation_info"), @@ -186,14 +170,12 @@ enum class ConfigProperty( ) ) ), - FRIEND_FEED_MENU_POSITION("property.friend_feed_menu_buttons_position", - "description.friend_feed_menu_buttons_position", + FRIEND_FEED_MENU_POSITION("friend_feed_menu_buttons_position", ConfigCategory.UI_TWEAKS, ConfigIntegerValue(1) ), HIDE_UI_ELEMENTS( - "property.hide_ui_elements", - "description.hide_ui_elements", + "hide_ui_elements", ConfigCategory.UI_TWEAKS, ConfigStateListValue( listOf("remove_voice_record_button", "remove_stickers_button", "remove_cognac_button", "remove_call_buttons", "remove_camera_borders"), @@ -207,74 +189,62 @@ enum class ConfigProperty( ) ), STREAK_EXPIRATION_INFO( - "property.streak_expiration_info", - "description.streakexpirationinfo", + "streak_expiration_info", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), DISABLE_SNAP_SPLITTING( - "property.disable_snap_splitting", - "description.disable_snap_splitting", + "disable_snap_splitting", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), DISABLE_VIDEO_LENGTH_RESTRICTION( - "property.disable_video_length_restriction", - "description.disable_video_length_restriction", + "disable_video_length_restriction", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), - SNAPCHAT_PLUS("property.snapchat_plus", - "description.snapchat_plus", + SNAPCHAT_PLUS("snapchat_plus", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), - NEW_MAP_UI("property.new_map_ui", - "description.new_map_ui", + NEW_MAP_UI("new_map_ui", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), LOCATION_SPOOF( - "property.location_spoof", - "description.location_spoof", + "location_spoof", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), LATITUDE( - "property.latitude_value", - "description.latitude_value", + "latitude_value", ConfigCategory.UI_TWEAKS, ConfigStringValue("0.0000"), shouldAppearInSettings = false ), LONGITUDE( - "property.longitude_value", - "description.longitude_value", + "longitude_value", ConfigCategory.UI_TWEAKS, ConfigStringValue("0.0000"), shouldAppearInSettings = false ), MESSAGE_PREVIEW_LENGTH( - "property.message_preview_length", - "description.message_preview_length", + "message_preview_length", ConfigCategory.UI_TWEAKS, ConfigIntegerValue(20) ), UNLIMITED_CONVERSATION_PINNING( - "property.unlimited_conversation_pinning", - "description.unlimited_conversation_pinning", + "unlimited_conversation_pinning", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), DISABLE_SPOTLIGHT( - "property.disable_spotlight", - "description.disable_spotlight", + "disable_spotlight", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), ENABLE_APP_APPEARANCE( - "property.enable_app_appearance", - "description.enable_app_appearance", + "enable_app_appearance", ConfigCategory.UI_TWEAKS, ConfigStateValue(false) ), @@ -282,20 +252,17 @@ enum class ConfigProperty( //CAMERA CAMERA_DISABLE( - "property.disable_camera", - "description.disable_camera", + "disable_camera", ConfigCategory.CAMERA, ConfigStateValue(false) ), IMMERSIVE_CAMERA_PREVIEW( - "property.immersive_camera_preview", - "description.immersive_camera_preview", + "immersive_camera_preview", ConfigCategory.CAMERA, ConfigStateValue(false) ), OVERRIDE_PREVIEW_RESOLUTION( - "property.preview_resolution", - "description.preview_resolution", + "preview_resolution", ConfigCategory.CAMERA, ConfigStateSelection( CameraTweaks.resolutions, @@ -304,8 +271,7 @@ enum class ConfigProperty( disableValueLocalization = true ), OVERRIDE_PICTURE_RESOLUTION( - "property.picture_resolution", - "description.picture_resolution", + "picture_resolution", ConfigCategory.CAMERA, ConfigStateSelection( CameraTweaks.resolutions, @@ -314,22 +280,19 @@ enum class ConfigProperty( disableValueLocalization = true ), FORCE_HIGHEST_FRAME_RATE( - "property.force_highest_frame_rate", - "description.force_highest_frame_rate", + "force_highest_frame_rate", ConfigCategory.CAMERA, ConfigStateValue(false) ), FORCE_CAMERA_SOURCE_ENCODING( - "property.force_camera_source_encoding", - "description.force_camera_source_encoding", + "force_camera_source_encoding", ConfigCategory.CAMERA, ConfigStateValue(false) ), // UPDATES AUTO_UPDATER( - "property.auto_updater", - "description.auto_updater", + "auto_updater", ConfigCategory.UPDATES, ConfigStateSelection( listOf("DISABLED", "EVERY_LAUNCH", "DAILY", "WEEKLY"), @@ -339,32 +302,27 @@ enum class ConfigProperty( // EXPERIMENTAL DEBUGGING APP_PASSCODE( - "property.app_passcode", - "description.app_passcode", + "app_passcode", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStringValue("", isHidden = true) ), APP_LOCK_ON_RESUME( - "property.app_lock_on_resume", - "description.app_lock_on_resume", + "app_lock_on_resume", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStateValue(false) ), INFINITE_STORY_BOOST( - "property.infinite_story_boost", - "description.infinite_story_boost", + "infinite_story_boost", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStateValue(false) ), MEO_PASSCODE_BYPASS( - "property.meo_passcode_bypass", - "description.meo_passcode_bypass", + "meo_passcode_bypass", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStateValue(false) ), AMOLED_DARK_MODE( - "property.amoled_dark_mode", - "description.amoled_dark_mode", + "amoled_dark_mode", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStateValue(false) ); diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/LocalePair.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/LocalePair.kt @@ -0,0 +1,3 @@ +package me.rhunk.snapenhance.data + +data class LocalePair(val locale: String, val content: String)+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/ClientDownloadManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/ClientDownloadManager.kt @@ -1,82 +0,0 @@ -package me.rhunk.snapenhance.download - -import android.content.Intent -import android.os.Bundle -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.download.data.DownloadRequest -import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair -import me.rhunk.snapenhance.download.enums.DownloadMediaType - -class ClientDownloadManager ( - private val context: ModContext, - private val outputPath: String, - private val mediaDisplaySource: String?, - private val mediaDisplayType: String?, - private val iconUrl: String? -) { - private fun sendToBroadcastReceiver(bundle: Bundle) { - val intent = Intent() - intent.setClassName(BuildConfig.APPLICATION_ID, MediaDownloadReceiver::class.java.name) - intent.action = MediaDownloadReceiver.DOWNLOAD_ACTION - intent.putExtras(bundle) - context.androidContext.sendBroadcast(intent) - } - - private fun sendToBroadcastReceiver( - request: DownloadRequest, - extras: Bundle.() -> Unit = {} - ) { - sendToBroadcastReceiver(request.toBundle().apply { - putString("outputPath", outputPath) - putString("mediaDisplaySource", mediaDisplaySource) - putString("mediaDisplayType", mediaDisplayType) - putString("iconUrl", iconUrl) - }.apply(extras)) - } - - fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long) { - sendToBroadcastReceiver( - DownloadRequest( - inputMedias = arrayOf(playlistUrl), - inputTypes = arrayOf(DownloadMediaType.REMOTE_MEDIA.name), - flags = DownloadRequest.Flags.IS_DASH_PLAYLIST - ) - ) { - putBundle("dashOptions", Bundle().apply { - putLong("offsetTime", offsetTime) - putLong("duration", duration) - }) - } - } - - fun downloadMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { - sendToBroadcastReceiver( - DownloadRequest( - inputMedias = arrayOf(mediaData), - inputTypes = arrayOf(mediaType.name), - mediaEncryption = if (encryption != null) mapOf(mediaData to encryption) else mapOf() - ) - ) - } - - fun downloadMediaWithOverlay( - videoData: String, - overlayData: String, - videoType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, - overlayType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, - videoEncryption: MediaEncryptionKeyPair? = null, - overlayEncryption: MediaEncryptionKeyPair? = null) - { - val encryptionMap = mutableMapOf<String, MediaEncryptionKeyPair>() - - if (videoEncryption != null) encryptionMap[videoData] = videoEncryption - if (overlayEncryption != null) encryptionMap[overlayData] = overlayEncryption - sendToBroadcastReceiver(DownloadRequest( - inputMedias = arrayOf(videoData, overlayData), - inputTypes = arrayOf(videoType.name, overlayType.name), - mediaEncryption = encryptionMap, - flags = DownloadRequest.Flags.SHOULD_MERGE_OVERLAY - )) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt @@ -0,0 +1,82 @@ +package me.rhunk.snapenhance.download + +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.BuildConfig +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.download.data.DownloadRequest +import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.download.enums.DownloadMediaType + +class DownloadManagerClient ( + private val context: ModContext, + private val outputPath: String, + private val mediaDisplaySource: String?, + private val mediaDisplayType: String?, + private val iconUrl: String? +) { + private fun sendToBroadcastReceiver(bundle: Bundle) { + val intent = Intent() + intent.setClassName(BuildConfig.APPLICATION_ID, DownloadManagerReceiver::class.java.name) + intent.action = DownloadManagerReceiver.DOWNLOAD_ACTION + intent.putExtras(bundle) + context.androidContext.sendBroadcast(intent) + } + + private fun sendToBroadcastReceiver( + request: DownloadRequest, + extras: Bundle.() -> Unit = {} + ) { + sendToBroadcastReceiver(request.toBundle().apply { + putString("outputPath", outputPath) + putString("mediaDisplaySource", mediaDisplaySource) + putString("mediaDisplayType", mediaDisplayType) + putString("iconUrl", iconUrl) + }.apply(extras)) + } + + fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long) { + sendToBroadcastReceiver( + DownloadRequest( + inputMedias = arrayOf(playlistUrl), + inputTypes = arrayOf(DownloadMediaType.REMOTE_MEDIA.name), + flags = DownloadRequest.Flags.IS_DASH_PLAYLIST + ) + ) { + putBundle("dashOptions", Bundle().apply { + putLong("offsetTime", offsetTime) + putLong("duration", duration) + }) + } + } + + fun downloadMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { + sendToBroadcastReceiver( + DownloadRequest( + inputMedias = arrayOf(mediaData), + inputTypes = arrayOf(mediaType.name), + mediaEncryption = if (encryption != null) mapOf(mediaData to encryption) else mapOf() + ) + ) + } + + fun downloadMediaWithOverlay( + videoData: String, + overlayData: String, + videoType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, + overlayType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, + videoEncryption: MediaEncryptionKeyPair? = null, + overlayEncryption: MediaEncryptionKeyPair? = null) + { + val encryptionMap = mutableMapOf<String, MediaEncryptionKeyPair>() + + if (videoEncryption != null) encryptionMap[videoData] = videoEncryption + if (overlayEncryption != null) encryptionMap[overlayData] = overlayEncryption + sendToBroadcastReceiver(DownloadRequest( + inputMedias = arrayOf(videoData, overlayData), + inputTypes = arrayOf(videoType.name, overlayType.name), + mediaEncryption = encryptionMap, + flags = DownloadRequest.Flags.SHOULD_MERGE_OVERLAY + )) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerReceiver.kt @@ -0,0 +1,336 @@ +package me.rhunk.snapenhance.download + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.MediaScannerConnection +import android.os.Handler +import android.widget.Toast +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +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.data.FileType +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.SharedContext +import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.util.download.RemoteMediaResolver +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 +) + +/** + * DownloadManagerReceiver handles the download requests of the user + */ +@OptIn(ExperimentalEncodingApi::class) +class DownloadManagerReceiver : BroadcastReceiver() { + companion object { + const val DOWNLOAD_ACTION = "me.rhunk.snapenhance.download.DownloadManagerReceiver.DOWNLOAD_ACTION" + } + + private val translation by lazy { + SharedContext.translation.getCategory("download_manager_receiver") + } + + private lateinit var context: Context + + private fun runOnUIThread(block: () -> Unit) { + Handler(context.mainLooper).post(block) + } + + private fun shortToast(text: String) { + runOnUIThread { + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + } + + private fun longToast(text: String) { + runOnUIThread { + Toast.makeText(context, text, Toast.LENGTH_LONG).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 + } + + private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { + if (coroutineContext.job.isCancelled) return + + runCatching { + val fileType = FileType.fromFile(inputFile) + val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } + inputFile.copyTo(outputFile, overwrite = true) + + MediaScannerConnection.scanFile(context, arrayOf(outputFile.absolutePath), null, null) + + //print the path of the saved media + val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { + if (!it.endsWith("/")) "$it/" else it + } + + longToast( + translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", "")) + ) + + pendingDownload.outputFile = outputFile.absolutePath + pendingDownload.downloadStage = DownloadStage.SAVED + }.onFailure { + Logger.error(it) + longToast(translation.format("failed_gallery_toast", "error" to it.toString())) + 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.getInputMedias().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.getInputMedias().first().let { inputMedia -> + val mediaType = downloadRequest.getInputType(0)!! + 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.getDashOptions()!! + + val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) + val xmlData = dashPlaylistFile.outputStream() + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) + + longToast(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) + val outputFile = File.createTempFile("dash", ".mp4") + runCatching { + MediaDownloaderHelper.downloadDashChapterFile( + dashPlaylist = dashPlaylistFile, + output = outputFile, + startTime = dashOptions.offsetTime, + duration = dashOptions.duration) + saveMediaToGallery(outputFile, pendingDownloadObject) + }.onFailure { + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error(it) + longToast(translation.format("failed_processing_toast", "error" to it.toString())) + 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 + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != DOWNLOAD_ACTION) return + this.context = context + SharedContext.ensureInitialized(context) + + + val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) + + GlobalScope.launch(Dispatchers.IO) { + val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) + + 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() + + 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 { + longToast(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) + pendingDownloadObject.downloadStage = DownloadStage.MERGING + + MediaDownloaderHelper.mergeOverlayFile( + media = renamedMedia, + overlay = renamedOverlayMedia, + output = mergedOverlay + ) + + saveMediaToGallery(mergedOverlay, pendingDownloadObject) + }.onFailure { + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error(it) + longToast(translation.format("failed_processing_toast", "error" to it.toString())) + pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED + } + + mergedOverlay.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + } + + downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) + }.onFailure { + pendingDownloadObject.downloadStage = DownloadStage.FAILED + Logger.error(it) + longToast(translation["failed_generic_toast"]) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/MediaDownloadReceiver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/MediaDownloadReceiver.kt @@ -1,329 +0,0 @@ -package me.rhunk.snapenhance.download - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.media.MediaScannerConnection -import android.os.Handler -import android.widget.Toast -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -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.data.FileType -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.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -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 -) - -/** - * MediaDownloadReceiver handles the download of media files - */ -@OptIn(ExperimentalEncodingApi::class) -class MediaDownloadReceiver : BroadcastReceiver() { - companion object { - val downloadTaskManager = DownloadTaskManager() - const val DOWNLOAD_ACTION = "me.rhunk.snapenhance.download.MediaDownloadReceiver.DOWNLOAD_ACTION" - } - - private lateinit var context: Context - - private fun runOnUIThread(block: () -> Unit) { - Handler(context.mainLooper).post(block) - } - - private fun shortToast(text: String) { - runOnUIThread { - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() - } - } - - private fun longToast(text: String) { - runOnUIThread { - Toast.makeText(context, text, Toast.LENGTH_LONG).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 - } - - private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { - if (coroutineContext.job.isCancelled) return - - runCatching { - val fileType = FileType.fromFile(inputFile) - val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } - inputFile.copyTo(outputFile, overwrite = true) - - MediaScannerConnection.scanFile(context, arrayOf(outputFile.absolutePath), null, null) - - //print the path of the saved media - val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { - if (!it.endsWith("/")) "$it/" else it - } - - longToast("Saved media to ${outputFile.absolutePath.replace(parentName ?: "", "")}") - - pendingDownload.outputFile = outputFile.absolutePath - pendingDownload.downloadStage = DownloadStage.SAVED - }.onFailure { - Logger.error("Failed to save media to gallery", it) - longToast("Failed to save media to gallery") - 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.getInputMedias().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.getInputMedias().first().let { inputMedia -> - val mediaType = downloadRequest.getInputType(0)!! - 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.getDashOptions()!! - - val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) - val xmlData = dashPlaylistFile.outputStream() - TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) - - longToast("Downloading dash media...") - val outputFile = File.createTempFile("dash", ".mp4") - runCatching { - MediaDownloaderHelper.downloadDashChapterFile( - dashPlaylist = dashPlaylistFile, - output = outputFile, - startTime = dashOptions.offsetTime, - duration = dashOptions.duration) - saveMediaToGallery(outputFile, pendingDownloadObject) - }.onFailure { - if (coroutineContext.job.isCancelled) return@onFailure - Logger.error("failed to download dash media", it) - longToast("Failed to download dash media: ${it.message}") - 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 - } - - @OptIn(DelicateCoroutinesApi::class) - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != DOWNLOAD_ACTION) return - this.context = context - downloadTaskManager.init(context) - - val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) - - GlobalScope.launch(Dispatchers.IO) { - val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) - - 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() - - 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 { - longToast("Merging overlay...") - pendingDownloadObject.downloadStage = DownloadStage.MERGING - - MediaDownloaderHelper.mergeOverlayFile( - media = renamedMedia, - overlay = renamedOverlayMedia, - output = mergedOverlay - ) - - saveMediaToGallery(mergedOverlay, pendingDownloadObject) - }.onFailure { - if (coroutineContext.job.isCancelled) return@onFailure - Logger.error("failed to merge overlay", it) - longToast("Failed to merge overlay: ${it.message}") - pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED - } - - mergedOverlay.delete() - renamedOverlayMedia.delete() - renamedMedia.delete() - return@launch - } - - downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) - }.onFailure { - pendingDownloadObject.downloadStage = DownloadStage.FAILED - Logger.error("failed to download media", it) - longToast("Failed to download media: ${it.message}") - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt @@ -2,7 +2,7 @@ package me.rhunk.snapenhance.download.data import android.os.Bundle import kotlinx.coroutines.Job -import me.rhunk.snapenhance.download.MediaDownloadReceiver +import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.download.enums.DownloadStage data class PendingDownload( @@ -35,7 +35,7 @@ data class PendingDownload( set(value) = synchronized(this) { changeListener(_stage, value) _stage = value - MediaDownloadReceiver.downloadTaskManager.updateTask(this) + SharedContext.downloadTaskManager.updateTask(this) } fun isJobActive(): Boolean { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt @@ -62,21 +62,21 @@ class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVI context.runOnUiThread { AlertDialog.Builder(context.mainActivity) - .setTitle(context.translation.get("auto_updater.dialog_title")) + .setTitle(context.translation["auto_updater.dialog_title"]) .setMessage( - context.translation.get("auto_updater.dialog_message") - .replace("{version}", latestVersion) - .replace("{body}", releaseContentBody) + context.translation.format("auto_updater.dialog_message", + "version" to latestVersion, + "body" to releaseContentBody) ) - .setNegativeButton(context.translation.get("auto_updater.dialog_negative_button")) { dialog, _ -> + .setNegativeButton(context.translation["auto_updater.dialog_negative_button"]) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(context.translation.get("auto_updater.dialog_positive_button")) { dialog, _ -> + .setPositiveButton(context.translation["auto_updater.dialog_positive_button"]) { dialog, _ -> dialog.dismiss() - context.longToast(context.translation.get("auto_updater.downloading_toast")) + context.longToast(context.translation["auto_updater.downloading_toast"]) val request = DownloadManager.Request(Uri.parse(downloadEndpoint)) - .setTitle(context.translation.get("auto_updater.download_manager_notification_title")) + .setTitle(context.translation["auto_updater.download_manager_notification_title"]) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "latest-snapenhance.apk") .setMimeType("application/vnd.android.package-archive") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -19,7 +19,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap import me.rhunk.snapenhance.database.objects.FriendInfo -import me.rhunk.snapenhance.download.ClientDownloadManager +import me.rhunk.snapenhance.download.DownloadManagerClient import me.rhunk.snapenhance.download.data.toKeyPair import me.rhunk.snapenhance.download.enums.DownloadMediaType import me.rhunk.snapenhance.features.Feature @@ -59,7 +59,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam mediaDisplaySource: String? = null, mediaDisplayType: String? = null, friendInfo: FriendInfo? = null - ): ClientDownloadManager { + ): DownloadManagerClient { val iconUrl = friendInfo?.takeIf { it.bitmojiAvatarId != null && it.bitmojiSelfieId != null }?.let { @@ -71,7 +71,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam createNewFilePath(pathSuffix.hashCode(), pathSuffix) ).absolutePath - return ClientDownloadManager( + return DownloadManagerClient( context = context, mediaDisplaySource = mediaDisplaySource, mediaDisplayType = mediaDisplayType, @@ -143,7 +143,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } - private fun downloadOperaMedia(clientDownloadManager: ClientDownloadManager, mediaInfoMap: Map<MediaType, MediaInfo>) { + private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<MediaType, MediaInfo>) { if (mediaInfoMap.isEmpty()) return val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! @@ -153,7 +153,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam val overlayReference = overlay?.let { handleLocalReferences(it.uri) } overlay?.let { - clientDownloadManager.downloadMediaWithOverlay( + downloadManagerClient.downloadMediaWithOverlay( originalMediaInfoReference, overlayReference!!, DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), @@ -164,7 +164,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam return } - clientDownloadManager.downloadMedia( + downloadManagerClient.downloadMedia( originalMediaInfoReference, DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), originalMediaInfo.encryption?.toKeyPair() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -90,8 +90,7 @@ class FeatureManager(private val context: ModContext) : Manager { } private fun featureInitializer(isAsync: Boolean, param: Int, action: (Feature) -> Unit) { - features.forEach { feature -> - if (feature.loadParams and param == 0) return@forEach + features.filter { it.loadParams and param != 0 }.forEach { feature -> val callback = { runCatching { action(feature) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt @@ -1,43 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.manager.Manager -import java.util.Locale - -class TranslationManager( - private val context: ModContext -) : Manager { - private val translationMap = mutableMapOf<String, String>() - lateinit var locale: Locale - - override fun init() { - val messageLocaleResult = context.bridgeClient.fetchTranslations() - locale = messageLocaleResult.locale?.split("_")?.let { Locale(it[0], it[1]) } ?: Locale.getDefault() - - val translations = JsonParser.parseString(messageLocaleResult.content?.toString(Charsets.UTF_8)).asJsonObject - if (translations == null || translations.isJsonNull) { - context.crash("Failed to fetch translations") - return - } - - fun scanObject(jsonObject: JsonObject, prefix: String = "") { - jsonObject.entrySet().forEach { - if (it.value.isJsonPrimitive) { - translationMap["$prefix${it.key}"] = it.value.asString - } - if (!it.value.isJsonObject) return@forEach - scanObject(it.value.asJsonObject, "$prefix${it.key}.") - } - } - - scanObject(translations) - } - - - fun get(key: String): String { - return translationMap[key] ?: key.also { Logger.xposedLog("Missing translation for $key") } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt @@ -31,6 +31,7 @@ import java.net.URL import kotlin.concurrent.thread class DownloadListAdapter( + private val activity: DownloadManagerActivity, private val downloadList: MutableList<PendingDownload> ): Adapter<DownloadListAdapter.ViewHolder>() { private val previewJobs = mutableMapOf<Int, Job>() @@ -56,15 +57,6 @@ class DownloadListAdapter( return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.download_manager_item, parent, false)) } - override fun onViewRecycled(holder: ViewHolder) { - val download = downloadList.getOrNull(holder.bindingAdapterPosition) ?: return - - previewJobs[holder.hashCode()]?.let { - it.cancel() - previewJobs.remove(holder.hashCode()) - } - } - override fun getItemCount(): Int { return downloadList.size } @@ -99,6 +91,11 @@ class DownloadListAdapter( } } + private val openButtonText by lazy { + activity.translation["button.open"] + } + + private fun updateViewHolder(download: PendingDownload, holder: ViewHolder) { holder.status.text = download.downloadStage.toString() holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background) @@ -117,7 +114,10 @@ class DownloadListAdapter( alpha = if (canInteract) 1f else 0.5f background = context.getDrawable(if (isSaved) R.drawable.action_button_success else R.drawable.action_button_cancel) setTextColor(context.getColor(if (isSaved) R.color.successColor else R.color.actionBarColor)) - text = if (isSaved) "Open" else "Cancel" + text = if (isSaved) + activity.translation["button.open"] + else + activity.translation["button.cancel"] } } @@ -171,7 +171,7 @@ class DownloadListAdapter( pendingDownload.outputFile?.let { val file = File(it) if (!file.exists()) { - Toast.makeText(holder.view.context, "File does not exist", Toast.LENGTH_SHORT).show() + Toast.makeText(holder.view.context, activity.translation["file_not_found_toast"], Toast.LENGTH_SHORT).show() return@setOnClickListener } val intent = Intent(Intent.ACTION_VIEW) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt @@ -10,7 +10,6 @@ import android.os.Bundle import android.os.PowerManager import android.provider.Settings import android.view.View -import android.view.ViewGroup import android.widget.Button import android.widget.ImageButton import android.widget.TextView @@ -18,24 +17,24 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.download.MediaDownloadReceiver +import me.rhunk.snapenhance.bridge.TranslationWrapper +import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.download.data.PendingDownload class DownloadManagerActivity : Activity() { + lateinit var translation: TranslationWrapper + private val backCallbacks = mutableListOf<() -> Unit>() private val fetchedDownloadTasks = mutableListOf<PendingDownload>() private var listFilter = MediaFilter.NONE - private val downloadTaskManager by lazy { - MediaDownloadReceiver.downloadTaskManager.also { it.init(this) } - } - private val preferences by lazy { getSharedPreferences("settings", Context.MODE_PRIVATE) } private fun updateNoDownloadText() { - findViewById<View>(R.id.no_download_title).let { + findViewById<TextView>(R.id.no_download_title).let { + it.text = translation["no_downloads"] it.visibility = if (fetchedDownloadTasks.isEmpty()) View.VISIBLE else View.GONE } } @@ -43,7 +42,7 @@ class DownloadManagerActivity : Activity() { @SuppressLint("NotifyDataSetChanged") private fun updateListContent() { fetchedDownloadTasks.clear() - fetchedDownloadTasks.addAll(downloadTaskManager.queryAllTasks(filter = listFilter).values) + fetchedDownloadTasks.addAll(SharedContext.downloadTaskManager.queryAllTasks(filter = listFilter).values) with(findViewById<RecyclerView>(R.id.download_list)) { adapter?.notifyDataSetChanged() @@ -68,6 +67,8 @@ class DownloadManagerActivity : Activity() { @SuppressLint("BatteryLife", "NotifyDataSetChanged", "SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + SharedContext.ensureInitialized(this) + translation = SharedContext.translation.getCategory("download_manager_activity") setContentView(R.layout.download_manager_activity) @@ -75,13 +76,13 @@ class DownloadManagerActivity : Activity() { findViewById<TextView>(R.id.title).text = resources.getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME findViewById<ImageButton>(R.id.settings_button).setOnClickListener { - SettingLayoutInflater(this).inflate(findViewById<ViewGroup>(android.R.id.content)) + SettingLayoutInflater(this).inflate(findViewById(android.R.id.content)) } with(findViewById<RecyclerView>(R.id.download_list)) { layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this@DownloadManagerActivity) - adapter = DownloadListAdapter(fetchedDownloadTasks).apply { + adapter = DownloadListAdapter(this@DownloadManagerActivity, fetchedDownloadTasks).apply { registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { updateNoDownloadText() @@ -113,7 +114,7 @@ class DownloadManagerActivity : Activity() { @SuppressLint("NotifyDataSetChanged") override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { fetchedDownloadTasks.removeAt(viewHolder.absoluteAdapterPosition).let { - downloadTaskManager.removeTask(it) + SharedContext.downloadTaskManager.removeTask(it) } adapter?.notifyItemRemoved(viewHolder.absoluteAdapterPosition) } @@ -133,7 +134,7 @@ class DownloadManagerActivity : Activity() { if (lastVisibleItemPosition == fetchedDownloadTasks.size - 1 && !isLoading) { isLoading = true - downloadTaskManager.queryTasks(fetchedDownloadTasks.last().id, filter = listFilter).forEach { + SharedContext.downloadTaskManager.queryTasks(fetchedDownloadTasks.last().id, filter = listFilter).forEach { fetchedDownloadTasks.add(it.value) adapter?.notifyItemInserted(fetchedDownloadTasks.size - 1) } @@ -151,7 +152,9 @@ class DownloadManagerActivity : Activity() { Pair(R.id.spotlight_category, MediaFilter.SPOTLIGHT) ).let { categoryPairs -> categoryPairs.forEach { pair -> - this@DownloadManagerActivity.findViewById<TextView>(pair.first).setOnClickListener { view -> + this@DownloadManagerActivity.findViewById<TextView>(pair.first).apply { + text = translation["category.${resources.getResourceEntryName(pair.first)}"] + }.setOnClickListener { view -> listFilter = pair.second updateListContent() categoryPairs.map { this@DownloadManagerActivity.findViewById<TextView>(it.first) }.forEach { @@ -162,12 +165,14 @@ class DownloadManagerActivity : Activity() { } } - this@DownloadManagerActivity.findViewById<Button>(R.id.remove_all_button).setOnClickListener { + this@DownloadManagerActivity.findViewById<Button>(R.id.remove_all_button).also { + it.text = translation["remove_all"] + }.setOnClickListener { with(AlertDialog.Builder(this@DownloadManagerActivity)) { - setTitle(R.string.remove_all_title) - setMessage(R.string.remove_all_text) - setPositiveButton("Yes") { _, _ -> - downloadTaskManager.removeAllTasks() + setTitle(translation["remove_all_title"]) + setMessage(translation["remove_all_text"]) + setPositiveButton(translation["button.positive"]) { _, _ -> + SharedContext.downloadTaskManager.removeAllTasks() fetchedDownloadTasks.removeIf { if (it.isJobActive()) it.cancel() true @@ -175,7 +180,7 @@ class DownloadManagerActivity : Activity() { adapter?.notifyDataSetChanged() updateNoDownloadText() } - setNegativeButton("Cancel") { dialog, _ -> + setNegativeButton(translation["button.negative"]) { dialog, _ -> dialog.dismiss() } show() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/SettingLayoutInflater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/SettingLayoutInflater.kt @@ -39,49 +39,56 @@ class SettingLayoutInflater( AlertDialog.Builder(activity) .setTitle(title) .setMessage(message) - .setPositiveButton("Yes") { _, _ -> + .setPositiveButton(activity.translation["button.positive"]) { _, _ -> action() } - .setNegativeButton("No") { _, _ -> } + .setNegativeButton(activity.translation["button.negative"]) { _, _ -> } .show() } } + private fun showSuccessToast() { + Toast.makeText(activity, "Success", Toast.LENGTH_SHORT).show() + } fun inflate(parent: ViewGroup) { val settingsView = activity.layoutInflater.inflate(R.layout.settings_page, parent, false) + val settingTranslation = activity.translation.getCategory("settings_page") + settingsView.findViewById<ImageButton>(R.id.settings_button).setOnClickListener { parent.removeView(settingsView) } + settingsView.findViewById<TextView>(R.id.title).text = activity.translation["settings"] + settingsView.findViewById<ListView>(R.id.setting_page_list).apply { adapter = SettingAdapter(activity, R.layout.setting_item, mutableListOf<Pair<String, () -> Unit>>().apply { - add("Clear Cache" to { + add(settingTranslation["clear_cache_title"] to { context.cacheDir.listFiles()?.forEach { it.deleteRecursively() } - Toast.makeText(context, "Cache cleared", Toast.LENGTH_SHORT).show() + showSuccessToast() }) BridgeFileType.values().forEach { fileType -> - val actionName = "Clear ${fileType.displayName} File" + val actionName = settingTranslation.format("clear_file_title", "file_name" to fileType.displayName) add(actionName to { - confirmAction(actionName, "Are you sure you want to clear ${fileType.displayName} file?") { + confirmAction(actionName, settingTranslation.format("clear_file_confirmation", "file_name" to fileType.displayName)) { fileType.resolve(context).deleteRecursively() - Toast.makeText(context, "${fileType.displayName} file cleared", Toast.LENGTH_SHORT).show() + showSuccessToast() } }) } - add("Reset All" to { - confirmAction("Reset All", "Are you sure you want to reset all?") { + add(settingTranslation["reset_all_title"] to { + confirmAction(settingTranslation["reset_all_title"], settingTranslation["reset_all_confirmation"]) { arrayOf(context.cacheDir, context.filesDir, File(context.dataDir, "databases"), File(context.dataDir, "shared_prefs")).forEach { it.listFiles()?.forEach { file -> file.deleteRecursively() } } - Toast.makeText(context, "Success!", Toast.LENGTH_SHORT).show() + showSuccessToast() } }) }.toTypedArray()) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt @@ -57,7 +57,7 @@ class ChatActionMenu : AbstractMenu() { if (context.config.bool(ConfigProperty.CHAT_DOWNLOAD_CONTEXT_MENU)) { injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.preview_button") + text = this@ChatActionMenu.context.translation["chat_action_menu.preview_button"] setOnClickListener { closeActionMenu() this@ChatActionMenu.context.executeAsync { this@ChatActionMenu.context.feature(MediaDownloader::class).onMessageActionMenu(true) } @@ -65,7 +65,7 @@ class ChatActionMenu : AbstractMenu() { }) injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.download_button") + text = this@ChatActionMenu.context.translation["chat_action_menu.download_button"] setOnClickListener { closeActionMenu() this@ChatActionMenu.context.executeAsync { @@ -80,7 +80,7 @@ class ChatActionMenu : AbstractMenu() { //delete logged message button if (context.config.bool(ConfigProperty.MESSAGE_LOGGER)) { injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.delete_logged_message_button") + text = this@ChatActionMenu.context.translation["chat_action_menu.delete_logged_message_button"] setOnClickListener { closeActionMenu() this@ChatActionMenu.context.executeAsync { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -80,17 +80,18 @@ class FriendFeedInfoMenu : AbstractMenu() { val birthday = Calendar.getInstance() birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 val message: String = """ - ${context.translation.get("profile_info.username")}: ${profile.username} - ${context.translation.get("profile_info.display_name")}: ${profile.displayName} - ${context.translation.get("profile_info.added_date")}: ${formatDate(addedTimestamp)} + ${context.translation["profile_info.username"]}: ${profile.username} + ${context.translation["profile_info.display_name"]}: ${profile.displayName} + ${context.translation["profile_info.added_date"]}: ${formatDate(addedTimestamp)} ${birthday.getDisplayName( Calendar.MONTH, Calendar.LONG, context.translation.locale )?.let { - context.translation.get("profile_info.birthday") - .replace("{month}", it) - .replace("{day}", profile.birthday.toInt().toString()) + context.translation.format("profile_info.birthday", + "month" to it, + "day" to birthday[Calendar.DAY_OF_MONTH].toString() + ) } } """.trimIndent() @@ -136,7 +137,7 @@ class FriendFeedInfoMenu : AbstractMenu() { } } - var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation.get("conversation_preview.unknown_user") + var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] if (displayUsername.length > 12) { displayUsername = displayUsername.substring(0, 13) + "... " @@ -152,22 +153,23 @@ class FriendFeedInfoMenu : AbstractMenu() { val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() messageBuilder.append("\n\n") .append("\uD83D\uDD25 ") //fire emoji - .append(context.translation.get("conversation_preview.streak_expiration").format( - timeSecondDiff / 60 / 24, - timeSecondDiff / 60 % 24, - timeSecondDiff % 60 + .append( + context.translation.format("conversation_preview.streak_expiration", + "day" to (timeSecondDiff / 60 / 24).toString(), + "hour" to (timeSecondDiff / 60 % 24).toString(), + "minute" to (timeSecondDiff % 60).toString() )) } //alert dialog val builder = AlertDialog.Builder(context.mainActivity) - builder.setTitle(context.translation.get("conversation_preview.title")) + builder.setTitle(context.translation["conversation_preview.title"]) builder.setMessage(messageBuilder.toString()) builder.setPositiveButton( "OK" ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } targetPerson?.let { - builder.setNegativeButton(context.translation.get("modal_option.profile_info")) {_, _ -> + builder.setNegativeButton(context.translation["modal_option.profile_info"]) { _, _ -> context.executeAsync { showProfileInfo(it) } @@ -208,7 +210,7 @@ class FriendFeedInfoMenu : AbstractMenu() { private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { val switch = Switch(context.androidContext) - switch.text = context.translation.get(text) + switch.text = context.translation[text] switch.isChecked = isChecked() ViewAppearanceHelper.applyTheme(switch) switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> @@ -231,7 +233,7 @@ class FriendFeedInfoMenu : AbstractMenu() { if (!context.config.bool(ConfigProperty.ENABLE_FRIEND_FEED_MENU_BAR)) { //preview button val previewButton = Button(viewModel.context).apply { - text = modContext.translation.get("friend_menu_option.preview") + text = modContext.translation["friend_menu_option.preview"] ViewAppearanceHelper.applyTheme(this, viewModel.width) setOnClickListener { showPreview( @@ -244,7 +246,7 @@ class FriendFeedInfoMenu : AbstractMenu() { //stealth switch val stealthSwitch = Switch(viewModel.context).apply { - text = modContext.translation.get("friend_menu_option.stealth_mode") + text = modContext.translation["friend_menu_option.stealth_mode"] isChecked = modContext.feature(StealthMode::class).isStealth(conversationId) ViewAppearanceHelper.applyTheme(this) setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt @@ -70,7 +70,7 @@ class OperaContextActionMenu : AbstractMenu() { ViewGroup.LayoutParams.MATCH_PARENT ) val button = Button(childView.getContext()) - button.text = context.translation.get("opera_context_menu.download") + button.text = context.translation["opera_context_menu.download"] button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } applyTheme(button) linearLayout.addView(button) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt @@ -26,7 +26,7 @@ class SettingsMenu : AbstractMenu() { @SuppressLint("ClickableViewAccessibility") private fun createCategoryTitle(key: String): TextView { val categoryText = TextView(context.androidContext) - categoryText.text = context.translation.get(key) + categoryText.text = context.translation[key] ViewAppearanceHelper.applyTheme(categoryText) categoryText.textSize = 20f categoryText.typeface = categoryText.typeface?.let { Typeface.create(it, Typeface.BOLD) } @@ -36,7 +36,7 @@ class SettingsMenu : AbstractMenu() { @SuppressLint("SetTextI18n") private fun createPropertyView(property: ConfigProperty): View { - val propertyName = context.translation.get(property.nameKey) + val propertyName = context.translation["property.${property.translationKey}"] val updateButtonText: (TextView, String) -> Unit = { textView, text -> textView.text = "$propertyName${if (text.isEmpty()) "" else ": $text"}" } @@ -50,7 +50,7 @@ class SettingsMenu : AbstractMenu() { if (property.disableValueLocalization) { it } else { - context.translation.get("option." + property.nameKey + "." + it) + context.translation["option.property." + property.translationKey + "." + it] } } }) @@ -129,7 +129,7 @@ class SettingsMenu : AbstractMenu() { builder.setSingleChoiceItems( property.valueContainer.keys().toTypedArray().map { if (property.disableValueLocalization) it - else context.translation.get("option." + property.nameKey + "." + it) + else context.translation["option.property." + property.translationKey + "." + it] }.toTypedArray(), property.valueContainer.keys().indexOf(property.valueContainer.value()) ) { _, which -> @@ -158,7 +158,7 @@ class SettingsMenu : AbstractMenu() { builder.setMultiChoiceItems( sortedStates.toSortedMap().map { if (property.disableValueLocalization) it.key - else context.translation.get("option." + property.nameKey + "." + it.key) + else context.translation["option.property." + property.translationKey + "." + it.key] }.toTypedArray(), sortedStates.map { it.value }.toBooleanArray() ) { _, which, isChecked -> @@ -212,7 +212,7 @@ class SettingsMenu : AbstractMenu() { val actions = context.actionManager.getActions().map { Pair(it) { val button = Button(viewModel.context) - button.text = context.translation.get(it.nameKey) + button.text = context.translation[it.nameKey] button.setOnClickListener { _ -> it.run() } diff --git a/app/src/main/res/layout/download_manager_activity.xml b/app/src/main/res/layout/download_manager_activity.xml @@ -34,7 +34,7 @@ android:src="@drawable/settings_icon" android:layout_gravity="center_vertical|end" android:padding="8dp" - android:contentDescription="@string/settings" /> + android:contentDescription="@null" /> </FrameLayout> @@ -53,7 +53,7 @@ android:focusable="true" android:fontFamily="@font/avenir_next_medium" android:gravity="center" - android:text="@string/all_category" + android:text="" android:textColor="@color/focusedCategoryColor" /> <TextView @@ -65,7 +65,7 @@ android:focusable="true" android:fontFamily="@font/avenir_next_medium" android:gravity="center" - android:text="@string/pending_category" + android:text="" android:textColor="@color/primaryText" /> <TextView @@ -77,7 +77,7 @@ android:focusable="true" android:fontFamily="@font/avenir_next_medium" android:gravity="center" - android:text="@string/snap_category" + android:text="" android:textColor="@color/primaryText" /> <TextView @@ -89,7 +89,7 @@ android:focusable="true" android:fontFamily="@font/avenir_next_medium" android:gravity="center" - android:text="@string/story_category" + android:text="" android:textColor="@color/primaryText" /> <TextView @@ -101,7 +101,7 @@ android:focusable="true" android:fontFamily="@font/avenir_next_medium" android:gravity="center" - android:text="@string/spotlight_category" + android:text="" android:textColor="@color/primaryText" /> </LinearLayout> @@ -124,7 +124,7 @@ android:layout_centerInParent="true" android:gravity="center" android:paddingVertical="40dp" - android:text="@string/no_downloads" + android:text="" android:textColor="@color/primaryText" /> </RelativeLayout> @@ -141,7 +141,7 @@ android:layout_height="45dp" android:background="@drawable/action_button_cancel" android:padding="5dp" - android:text="@string/remove_all" + android:text="" android:textColor="@color/darkText" android:layout_gravity="center_horizontal|bottom" android:layout_marginBottom="5dp"/> diff --git a/app/src/main/res/layout/download_manager_item.xml b/app/src/main/res/layout/download_manager_item.xml @@ -71,7 +71,7 @@ android:background="@drawable/action_button_cancel" android:padding="5dp" android:layout_marginEnd="10dp" - android:text="@string/cancel" + android:text="" android:textColor="@color/darkText" tools:ignore="ButtonOrder" /> </LinearLayout> diff --git a/app/src/main/res/layout/settings_page.xml b/app/src/main/res/layout/settings_page.xml @@ -34,7 +34,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" - android:text="@string/settings" + android:text="" android:textColor="@color/primaryText" android:textSize="23sp" android:fontFamily="@font/avenir_next_bold" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml @@ -1,15 +1,3 @@ <resources> <string name="app_name" translatable="false">SnapEnhance</string> - <string name="remove_all_title">Remove all Downloads</string> - <string name="remove_all_text">Are you sure you want to do this?</string> - <string name="remove_all">Remove All</string> - <string name="no_downloads">No downloads</string> - <string name="cancel">Cancel</string> - <string name="all_category">All</string> - <string name="pending_category">Pending</string> - <string name="snap_category">Snaps</string> - <string name="story_category">Stories</string> - <string name="spotlight_category">Spotlight</string> - <string name="settings">Settings</string> - <string name="refresh_mappings">Refresh Mappings</string> </resources> \ No newline at end of file