commit ce9833f2c38831852b3a196922c237520d0485f4 parent 151c804629a9dca3de9ba155e9e6dd376f256a00 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 27 Jul 2023 15:32:50 +0200 refactor: bridge and mapper (#130) * feat: fingerprint spoof * fetch upstream - fix errors * fix(ci): wrong env * fix all items being shown and translations * use filter instead of check * fix(snap_enums/ContentType): status plus gift * refactor: download bridge * fix: check if bridge svc is correctly installed * refactor bridge - async handlers - optimized reply callback calls - add timeout debug when sending a bridge msg * fix: check for bridge correctly initialized instead of connected * fix: android compat + suppress warnings * fix(feature/manager): set a fixed thread pool * feat: dex mapper * build: libs catalog * fix: infinite loading * refactor: config ui items * feat: disable google play services dialogs * fix(mapper): getClassName * fix(bridge/force_start): use broadcast receiver instead of activity * fix: notification filter * refactor: aidl * fix(bridge): bindServiceAsUser handler * refactor: download manager * fix(download/callback): add outputPath * fix(mapper): lspatch support * fix(notifications): blacklist & reply - config value container translation key * feat: download from notifications * fix(notification/reply): use FLAG_ONLY_ALERT_ONCE * fix: alert dialog builder theme * hooker method inline * fix: immersive camera preview * fix: chat action menu design * fix(download/processor): fallback toasts * fix(message_logger): limit thread pool to 10 * feat(download): logging * fix: screenshot detection in snaps * feat: hide friend suggestions * refactor: download content URIs * feat: preview group chats * fix(config): ask for save folder * fix(download_manager): async item preview * fix(media_downloader): story replies * fix(notifications): concurrent exception * build: kotlin dsl * fix(ci): upload only on main * fix(build): getVersion task * fix(build): abi filters * fix(build): default product flavor * test(mapper): mapping tests * fix(mappings): group chat preview * fix(mapper): dexlib interfaces instead of impl --------- Co-authored-by: auth <64337177+authorisation@users.noreply.github.com> Diffstat:
131 files changed, 3371 insertions(+), 3050 deletions(-)
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml @@ -71,7 +71,9 @@ jobs: path: app/build/outputs/apk/armv7/debug/*.apk - name: CI Upload armv8 + if: github.ref_name == 'main' run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv8/debug/app-${{ env.version }}-armv8-${{ steps.version-env.outputs.sha_short }}.apk" --caption "A new commit has been pushed to the ${{ env.GIT_BRANCH_NAME }} branch! ${{ steps.version-env.outputs.sha_short }}" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}" - name: CI Upload armv7 + if: github.ref_name == 'main' run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv7/debug/app-${{ env.version }}-armv7-${{ steps.version-env.outputs.sha_short }}.apk" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}" diff --git a/app/build.gradle b/app/build.gradle @@ -1,110 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -def appVersionName = "1.1.0" -def appVersionCode = 7 - -android { - compileSdk 33 - buildToolsVersion = "33.0.2" - - defaultConfig { - applicationId "me.rhunk.snapenhance" - minSdk 28 - targetSdk 33 - versionCode appVersionCode - versionName appVersionName - multiDexEnabled true - } - - buildTypes { - release { - minifyEnabled false - shrinkResources false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { - outputFileName = "app-${appVersionName}-${variant.flavorName}.apk" - } - } - - flavorDimensions "release" - - productFlavors { - armv8 { - getIsDefault().set(true) - ndk { - //noinspection ChromeOsAbiSupport - abiFilters "arm64-v8a" - } - dimension "release" - } - armv7 { - ndk { - //noinspection ChromeOsAbiSupport - abiFilters "armeabi-v7a" - } - packagingOptions { - exclude 'lib/armeabi-v7a/*_neon.so' - } - dimension "release" - } - } - - kotlinOptions { - jvmTarget = '1.8' - } - namespace 'me.rhunk.snapenhance' -} - -afterEvaluate { - //auto install for debug purpose - getTasks().getByPath(":app:assembleArmv8Debug").doLast { - def apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == "armv8" }.outputs[0].outputFile - try { - println "Killing Snapchat" - exec { - commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android" - } - println "Installing debug build" - exec() { - commandLine "adb", "install", "-r", "-d", apkDebugFile.absolutePath - } - println "Starting Snapchat" - exec { - commandLine "adb", "shell", "am", "start", "com.snapchat.android" - } - } catch (Throwable t) { - println "Failed to install debug build" - t.printStackTrace() - } - } -} - -task getVersion { - doLast { - def version = new File('app/build/version.txt') - version.text = android.defaultConfig.versionName - } -} - -dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' - implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21' - implementation 'androidx.recyclerview:recyclerview:1.3.0' - - compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar') - implementation 'com.google.code.gson:gson:2.10.1' - implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1.LTS' - implementation 'org.osmdroid:osmdroid-android:6.1.16' - implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -0,0 +1,119 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl + +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) +} + +val appVersionName = "1.1.0" +val appVersionCode = 7 + +android { + namespace = "me.rhunk.snapenhance" + compileSdk = 33 + + buildFeatures { + aidl = true + } + + defaultConfig { + applicationId = "me.rhunk.snapenhance" + minSdk = 28 + //noinspection OldTargetApi + targetSdk = 33 + + versionCode = appVersionCode + versionName = appVersionName + multiDexEnabled = true + } + + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + } + } + + flavorDimensions += "abi" + + productFlavors { + create("armv8") { + ndk { + abiFilters.add("arm64-v8a") + } + + dimension = "abi" + } + + create("armv7") { + ndk { + abiFilters.add("armeabi-v7a") + } + packaging { + jniLibs { + excludes += "**/*_neon.so" + } + } + dimension = "abi" + } + } + + properties["debug_flavor"]?.let { + android.productFlavors[it.toString()].setIsDefault(true) + } + + applicationVariants.all { + outputs.map { it as BaseVariantOutputImpl }.forEach { variant -> + variant.outputFileName = "app-${appVersionName}-${variant.name}.apk" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + compileOnly(files("libs/LSPosed-api-1.0-SNAPSHOT.jar")) + implementation(libs.coroutines) + implementation(libs.kotlin.reflect) + implementation(libs.recyclerview) + implementation(libs.gson) + implementation(libs.ffmpeg.kit) + implementation(libs.osmdroid.android) + implementation(libs.okhttp) + implementation(libs.androidx.documentfile) + + implementation(project(":mapper")) +} + +tasks.register("getVersion") { + doLast { + val versionFile = File("app/build/version.txt") + versionFile.writeText(android.defaultConfig.versionName.toString()) + } +} + +afterEvaluate { + properties["debug_assemble_task"]?.let { tasks.named(it.toString()) }?.orNull?.doLast { + runCatching { + val apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == properties["debug_flavor"] }?.outputs?.first()?.outputFile ?: return@doLast + exec { + commandLine("adb", "shell", "am", "force-stop", "com.snapchat.android") + } + exec { + commandLine("adb", "install", "-r", "-d", apkDebugFile.absolutePath) + } + exec { + commandLine("adb", "shell", "am", "start", "com.snapchat.android") + } + } + } +}+ \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile- \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -32,18 +32,11 @@ android:resource="@array/sc_scope" /> <service - android:name=".bridge.service.BridgeService" + android:name=".bridge.BridgeService" android:exported="true" tools:ignore="ExportedService"> </service> - <receiver android:name=".download.DownloadManagerReceiver" android:exported="true" - tools:ignore="ExportedReceiver"> - <intent-filter> - <action android:name="me.rhunk.snapenhance.download.DownloadManagerReceiver.DOWNLOAD_ACTION" /> - </intent-filter> - </receiver> - <activity android:name=".ui.download.DownloadManagerActivity" android:theme="@style/AppTheme" @@ -62,6 +55,15 @@ android:theme="@style/AppTheme" android:excludeFromRecents="true" android:exported="true" /> + <activity + android:name=".ui.spoof.DeviceSpooferActivity" + android:theme="@style/AppTheme" + android:excludeFromRecents="true" + android:exported="true" /> + <activity android:name=".bridge.ForceStartActivity" + android:theme="@android:style/Theme.NoDisplay" + android:excludeFromRecents="true" + android:exported="true" /> </application> </manifest> \ No newline at end of file diff --git a/app/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/app/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -0,0 +1,108 @@ +package me.rhunk.snapenhance.bridge; + +import java.util.List; +import me.rhunk.snapenhance.bridge.DownloadCallback; + +interface BridgeInterface { + /** + * Create a file if it doesn't exist, and read it + * + * @param fileType the type of file to create and read + * @param defaultContent the default content to write to the file if it doesn't exist + * @return the content of the file + */ + byte[] createAndReadFile(int fileType, in byte[] defaultContent); + + /** + * Read a file + * + * @param fileType the type of file to read + * @return the content of the file + */ + byte[] readFile(int fileType); + + /** + * Write a file + * + * @param fileType the type of file to write + * @param content the content to write to the file + * @return true if the file was written successfully + */ + boolean writeFile(int fileType, in byte[] content); + + /** + * Delete a file + * + * @param fileType the type of file to delete + * @return true if the file was deleted successfully + */ + boolean deleteFile(int fileType); + + /** + * Check if a file exists + * + * @param fileType the type of file to check + * @return true if the file exists + */ + boolean isFileExists(int fileType); + + /** + * Get the content of a logged message from the database + * + * @param conversationId the ID of the conversation + * @return the content of the message + */ + long[] getLoggedMessageIds(String conversationId, int limit); + + /** + * Get the content of a logged message from the database + * + * @param id the ID of the message logger message + * @return the content of the message + */ + @nullable byte[] getMessageLoggerMessage(String conversationId, long id); + + /** + * Add a message to the message logger database + * + * @param id the ID of the message logger message + * @param message the content of the message + */ + void addMessageLoggerMessage(String conversationId, long id, in byte[] message); + + /** + * Delete a message from the message logger database + * + * @param id the ID of the message logger message + */ + void deleteMessageLoggerMessage(String conversationId, long id); + + /** + * Clear the message logger database + */ + void clearMessageLogger(); + + /** + * Fetch the translations + * + * @return the translations result + */ + Map<String, String> fetchTranslations(); + + /** + * Get check for updates last time + * @return the last time check for updates was done + */ + long getAutoUpdaterTime(); + + /** + * Set check for updates last time + * @param time the time to set + */ + void setAutoUpdaterTime(long time); + + /** + * Enqueue a download + */ + void enqueueDownload(in Intent intent, DownloadCallback callback); +}+ \ No newline at end of file diff --git a/app/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl b/app/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.bridge; + +oneway interface DownloadCallback { + void onSuccess(String outputPath); + void onProgress(String message); + void onFailure(String message, @nullable String throwable); +} diff --git a/app/src/main/assets/lang/en_US.json b/app/src/main/assets/lang/en_US.json @@ -50,13 +50,9 @@ "name": "Unlimited Snap View Time", "description": "Removes the time limit for viewing Snaps" }, - "prevent_screenshot_notifications": { - "name": "Prevent Screenshot Notifications", - "description": "Prevents anyone from knowing that you've taken a screenshot" - }, - "prevent_status_notifications": { - "name": "Prevent Status Notifications", - "description": "Prevents sending status notifications\ne.g. Saved to camera roll" + "prevent_sending_messages": { + "name": "Prevent Sending Messages", + "description": "Prevents sending certain types of messages" }, "anonymous_story_view": { "name": "Anonymous Story View", @@ -95,6 +91,10 @@ "name": "Force Media Source Quality", "description": "Overrides the media source quality" }, + "download_logging": { + "name": "Download Logging", + "description": "Show a toast when media is downloading" + }, "enable_friend_feed_menu_bar": { "name": "Friend Feed Menu Bar", @@ -164,6 +164,10 @@ "name": "Override Startup Page", "description": "Overrides the startup page" }, + "disable_google_play_dialogs": { + "name": "Disable Google Play Services Dialogs", + "description": "Prevent Google Play Services availability dialogs from being shown" + }, "auto_updater": { "name": "Auto Updater", @@ -218,6 +222,18 @@ "unlimited_multi_snap": { "name": "Unlimited Multi Snap", "description": "Allows you to take an unlimited amount of multi snaps" + }, + "device_spoof": { + "name": "Spoof Device Values", + "description": "Spoofs the devices values" + }, + "device_fingerprint": { + "name": "Device Fingerprint", + "description": "Spoofs the device fingerprint" + }, + "android_id": { + "name": "Android ID", + "description": "Spoofs the devices Android ID" } }, @@ -226,7 +242,8 @@ "better_notifications": { "chat": "Show chat messages", "snap": "Show medias", - "reply_button": "Add reply button" + "reply_button": "Add reply button", + "download_button": "Add download button" }, "friend_feed_menu_buttons": { "auto_download_blacklist": "\u2B07\uFE0F Auto Download Blacklist", @@ -249,6 +266,12 @@ "public_stories": "Public Stories", "spotlight": "Spotlight" }, + "download_logging": { + "started": "Started", + "success": "Success", + "progress": "Progress", + "failure": "Failure" + }, "auto_save_messages": { "NOTE": "Audio Note", "CHAT": "Chat", @@ -256,10 +279,19 @@ "SNAP": "Snap", "STICKER": "Sticker" }, - "notification_blacklist": { + "notifications": { + "chat_screenshot": "Screenshot", + "chat_screen_record": "Screen Record", + "camera_roll_save": "Camera Roll Save", "chat": "Chat", + "chat_reply": "Chat Reply", "snap": "Snap", - "typing": "Typing" + "typing": "Typing", + "stories": "Stories", + "initiate_audio": "Incoming Audio Call", + "abandon_audio": "Missed Audio Call", + "initiate_video": "Incoming Video Call", + "abandon_video": "Missed Video Call" }, "gallery_media_send_override": { "OFF": "Off", @@ -287,6 +319,7 @@ "VERTICAL_STORY_VIEWER": "Enable Vertical Story Viewer" }, "hide_story_section": { + "hide_friend_suggestions": "Hide friend suggestions", "hide_friends": "Hide friends section", "hide_following": "Hide following section", "hide_for_you": "Hide For You section" @@ -401,16 +434,21 @@ "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!" + "success_toast": "Success!", + "device_spoofer": "Device Spoofer" } }, - "download_manager_receiver": { + "download_processor": { + "download_started_toast": "Download started", + "unsupported_content_type_toast": "Unsupported content type!", + "failed_no_longer_available_toast": "Media no longer available", "already_queued_toast": "Media already in queue!", "already_downloaded_toast": "Media already downloaded!", "saved_toast": "Saved to {path}", "download_toast": "Downloading {path}...", "processing_toast": "Processing {path}...", "failed_generic_toast": "Failed to download", + "failed_to_create_preview_toast": "Failed to create preview", "failed_processing_toast": "Failed to process {error}", "failed_gallery_toast": "Failed to save to gallery {error}" }, @@ -418,5 +456,8 @@ "title": "SnapEnhance Settings", "selected_text": "{count} selected", "invalid_number_toast": "Invalid number!" + }, + "spoof_activity": { + "title": "Spoof Settings" } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -11,9 +11,9 @@ 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.ConfigWrapper -import me.rhunk.snapenhance.bridge.TranslationWrapper +import me.rhunk.snapenhance.bridge.BridgeClient +import me.rhunk.snapenhance.bridge.wrapper.ConfigWrapper +import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper import me.rhunk.snapenhance.data.MessageSender import me.rhunk.snapenhance.database.DatabaseAccess import me.rhunk.snapenhance.features.Feature @@ -35,7 +35,7 @@ class ModContext { lateinit var androidContext: Context var mainActivity: Activity? = null - lateinit var bridgeClient: AbstractBridgeClient + lateinit var bridgeClient: BridgeClient val gson: Gson = GsonBuilder().create() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContext.kt @@ -7,7 +7,8 @@ import android.content.Intent import android.os.Build import android.os.Environment import android.provider.Settings -import me.rhunk.snapenhance.bridge.TranslationWrapper +import me.rhunk.snapenhance.bridge.wrapper.ConfigWrapper +import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper import me.rhunk.snapenhance.download.DownloadTaskManager import kotlin.system.exitProcess @@ -17,6 +18,7 @@ import kotlin.system.exitProcess object SharedContext { lateinit var downloadTaskManager: DownloadTaskManager lateinit var translation: TranslationWrapper + lateinit var config: ConfigWrapper private fun askForStoragePermission(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -36,17 +38,7 @@ object SharedContext { context.requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE), 0) } - fun ensureInitialized(context: Context) { - if (!this::downloadTaskManager.isInitialized) { - downloadTaskManager = DownloadTaskManager().apply { - init(context) - } - } - if (!this::translation.isInitialized) { - translation = TranslationWrapper().apply { - loadFromContext(context) - } - } + private fun askForPermissions(context: Context) { //ask for storage permission val hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -72,4 +64,22 @@ object SharedContext { } .show() } + + fun ensureInitialized(context: Context) { + if (!this::downloadTaskManager.isInitialized) { + downloadTaskManager = DownloadTaskManager().apply { + init(context) + } + } + if (!this::translation.isInitialized) { + translation = TranslationWrapper().apply { + loadFromContext(context) + } + } + if (!this::config.isInitialized) { + config = ConfigWrapper().apply { loadFromContext(context) } + } + + askForPermissions(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 @@ -1,16 +1,17 @@ package me.rhunk.snapenhance -import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.content.Context +import android.content.pm.PackageManager import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.client.ServiceBridgeClient +import me.rhunk.snapenhance.bridge.BridgeClient import me.rhunk.snapenhance.data.SnapClassCache import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook +import me.rhunk.snapenhance.util.getApplicationInfoCompat import kotlin.time.ExperimentalTime import kotlin.time.measureTime @@ -22,16 +23,24 @@ class SnapEnhance { } } private val appContext = ModContext() + private var isBridgeInitialized = false init { Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param -> appContext.androidContext = param.arg<Context>(0).also { classLoader = it.classLoader } - appContext.bridgeClient = provideBridgeClient() + appContext.bridgeClient = BridgeClient(appContext) + + //for lspatch builds, we need to check if the service is correctly installed + runCatching { + appContext.androidContext.packageManager.getApplicationInfoCompat(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA) + }.onFailure { + appContext.crash("SnapEnhance bridge service is not installed. Please download stable version from https://github.com/rhunk/SnapEnhance/releases") + return@hook + } appContext.bridgeClient.apply { - this.context = appContext start { bridgeResult -> if (!bridgeResult) { Logger.xposedLog("Cannot connect to bridge service") @@ -42,6 +51,8 @@ class SnapEnhance { runBlocking { init() } + }.onSuccess { + isBridgeInitialized = true }.onFailure { Logger.xposedLog("Failed to initialize", it) } @@ -49,7 +60,7 @@ class SnapEnhance { } } - Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) { + Activity::class.java.hook( "onCreate", HookStage.AFTER, { isBridgeInitialized }) { val activity = it.thisObject() as Activity if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook val isMainActivityNotNull = appContext.mainActivity != null @@ -61,7 +72,7 @@ class SnapEnhance { var activityWasResumed = false //we need to reload the config when the app is resumed - Hooker.hook(Activity::class.java, "onResume", HookStage.AFTER) { + Activity::class.java.hook("onResume", HookStage.AFTER, { isBridgeInitialized }) { val activity = it.thisObject() as Activity if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook @@ -76,14 +87,6 @@ class SnapEnhance { } } - @SuppressLint("ObsoleteSdkInt") - private fun provideBridgeClient(): AbstractBridgeClient { - /*if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { - return RootBridgeClient() - }*/ - return ServiceBridgeClient() - } - @OptIn(ExperimentalTime::class) private suspend fun init() { //load translations in a coroutine to speed up initialization @@ -100,7 +103,7 @@ class SnapEnhance { features.init() } }.also { time -> - Logger.debug("initialized in $time") + Logger.debug("init took $time") } } @@ -112,7 +115,7 @@ class SnapEnhance { actionManager.init() } }.also { time -> - Logger.debug("onActivityCreate in $time") + Logger.debug("onActivityCreate took $time") } } } \ No newline at end of file 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 @@ -19,6 +19,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.database.objects.FriendFeedInfo import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.export.ExportFormat import me.rhunk.snapenhance.util.export.MessageExporter @@ -61,7 +62,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { private suspend fun askExportType() = suspendCancellableCoroutine { cont -> context.runOnUiThread { - AlertDialog.Builder(context.mainActivity) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(context.translation["chat_export.select_export_format"]) .setItems(ExportFormat.values().map { it.name }.toTypedArray()) { _, which -> cont.resumeWith(Result.success(ExportFormat.values()[which])) @@ -82,7 +83,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { ContentType.NOTE, ContentType.STICKER ) - AlertDialog.Builder(context.mainActivity) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(context.translation["chat_export.select_media_type"]) .setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked -> val media = contentTypes[which] @@ -110,7 +111,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { val friendFeedEntries = context.database.getFriendFeed(20) val selectedConversations = mutableListOf<FriendFeedInfo>() - AlertDialog.Builder(context.mainActivity) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(context.translation["chat_export.select_conversation"]) .setMultiChoiceItems( friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayName!!.split("|").firstOrNull() }.toTypedArray(), @@ -248,7 +249,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { dialogLogs.clear() val jobs = mutableListOf<Job>() - currentActionDialog = AlertDialog.Builder(context.mainActivity) + currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(context.translation["chat_export.exporting_chats"]) .setCancelable(false) .setMessage("") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.action.impl import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType class RefreshMappings : AbstractAction("action.refresh_mappings") { override fun run() { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/AbstractBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/AbstractBridgeClient.kt @@ -1,122 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult - -abstract class AbstractBridgeClient { - lateinit var context: ModContext - - /** - * Start the bridge client - * - * @param callback the callback to call when the initialization is done - */ - abstract fun start(callback: (Boolean) -> Unit = {}) - - /** - * Create a file if it doesn't exist, and read it - * - * @param fileType the type of file to create and read - * @param defaultContent the default content to write to the file if it doesn't exist - * @return the content of the file - */ - abstract fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray - - /** - * Read a file - * - * @param fileType the type of file to read - * @return the content of the file - */ - abstract fun readFile(fileType: BridgeFileType): ByteArray - - /** - * Write a file - * - * @param fileType the type of file to write - * @param content the content to write to the file - * @return true if the file was written successfully - */ - abstract fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean - - /** - * Delete a file - * - * @param fileType the type of file to delete - * @return true if the file was deleted successfully - */ - abstract fun deleteFile(fileType: BridgeFileType): Boolean - - /** - * Check if a file exists - * - * @param fileType the type of file to check - * @return true if the file exists - */ - abstract fun isFileExists(fileType: BridgeFileType): Boolean - - /** - * Download content from a URL and save it to a file - * - * @param url the URL to download content from - * @param path the path to save the content to - * @return true if the content was downloaded successfully - */ - abstract fun downloadContent(url: String, path: String): Boolean - - /** - * Get the content of a logged message from the database - * - * @param conversationId the ID of the conversation - * @return the content of the message - */ - abstract fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> - - /** - * Get the content of a logged message from the database - * - * @param id the ID of the message logger message - * @return the content of the message - */ - abstract fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? - - /** - * Add a message to the message logger database - * - * @param id the ID of the message logger message - * @param message the content of the message - */ - abstract fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) - - /** - * Delete a message from the message logger database - * - * @param id the ID of the message logger message - */ - abstract fun deleteMessageLoggerMessage(conversationId: String, id: Long) - - /** - * Clear the message logger database - */ - abstract fun clearMessageLogger() - - /** - * Fetch the translations - * - * @return the translations result - */ - abstract fun fetchTranslations(): LocaleResult - - /** - * Get check for updates last time - * @return the last time check for updates was done - */ - abstract fun getAutoUpdaterTime(): Long - - /** - * Set check for updates last time - * @param time the time to set - */ - abstract fun setAutoUpdaterTime(time: Long) -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt @@ -0,0 +1,126 @@ +package me.rhunk.snapenhance.bridge + + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.IBinder +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.BuildConfig +import me.rhunk.snapenhance.Logger.xposedLog +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.bridge.types.BridgeFileType +import me.rhunk.snapenhance.data.LocalePair +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import kotlin.system.exitProcess + + +class BridgeClient( + private val context: ModContext +): ServiceConnection { + private lateinit var future: CompletableFuture<Boolean> + private lateinit var service: BridgeInterface + + fun start(callback: (Boolean) -> Unit) { + this.future = CompletableFuture() + + with(context.androidContext) { + //ensure the remote process is running + startActivity(Intent() + .setClassName(BuildConfig.APPLICATION_ID, ForceStartActivity::class.java.name) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + ) + + val intent = Intent() + .setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + bindService( + intent, + Context.BIND_AUTO_CREATE, + Executors.newSingleThreadExecutor(), + this@BridgeClient + ) + } else { + XposedHelpers.callMethod( + this, + "bindServiceAsUser", + intent, + this@BridgeClient, + Context.BIND_AUTO_CREATE, + Handler(HandlerThread("BridgeClient").apply { + start() + }.looper), + android.os.Process.myUserHandle() + ) + } + } + callback(future.get()) + } + + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + this.service = BridgeInterface.Stub.asInterface(service) + future.complete(true) + } + + override fun onNullBinding(name: ComponentName) { + xposedLog("failed to connect to bridge service") + future.complete(false) + } + + override fun onServiceDisconnected(name: ComponentName) { + exitProcess(0) + } + + fun createAndReadFile( + fileType: BridgeFileType, + defaultContent: ByteArray + ): ByteArray = service.createAndReadFile(fileType.value, defaultContent) + + fun readFile(fileType: BridgeFileType): ByteArray = service.readFile(fileType.value) + + fun writeFile( + fileType: BridgeFileType, + content: ByteArray? + ): Boolean = service.writeFile(fileType.value, content) + + fun deleteFile(fileType: BridgeFileType) = service.deleteFile(fileType.value) + + + fun isFileExists(fileType: BridgeFileType) = service.isFileExists(fileType.value) + + fun getLoggedMessageIds(conversationId: String, limit: Int): LongArray = service.getLoggedMessageIds(conversationId, limit) + + fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? = service.getMessageLoggerMessage(conversationId, id) + + fun addMessageLoggerMessage(conversationId: String,id: Long, message: ByteArray) = service.addMessageLoggerMessage(conversationId, id, message) + + fun deleteMessageLoggerMessage(conversationId: String, id: Long) = service.deleteMessageLoggerMessage(conversationId, id) + + fun clearMessageLogger() = service.clearMessageLogger() + + fun fetchTranslations() = service.fetchTranslations().map { + LocalePair(it.key, it.value) + } + + fun getAutoUpdaterTime(): Long { + createAndReadFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, "0".toByteArray()).run { + return if (isEmpty()) { + 0 + } else { + String(this).toLong() + } + } + } + + fun setAutoUpdaterTime(time: Long) { + writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray()) + } + + fun enqueueDownload(intent: Intent, callback: DownloadCallback) = service.enqueueDownload(intent, callback) +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -0,0 +1,105 @@ +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.TranslationWrapper +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() = TranslationWrapper.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) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ConfigWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ConfigWrapper.kt @@ -1,79 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import android.content.Context -import com.google.gson.GsonBuilder -import com.google.gson.JsonObject -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.config.ConfigAccessor -import me.rhunk.snapenhance.config.ConfigProperty - -class ConfigWrapper: ConfigAccessor() { - companion object { - private val gson = GsonBuilder().setPrettyPrinting().create() - } - - private lateinit var isFileExistsAction: () -> Boolean - private lateinit var writeFileAction: (ByteArray) -> Unit - private lateinit var readFileAction: () -> ByteArray - - fun load() { - ConfigProperty.sortedByCategory().forEach { key -> - set(key, key.valueContainer) - } - - if (!isFileExistsAction()) { - writeConfig() - return - } - - runCatching { - loadConfig() - }.onFailure { - Logger.error("Failed to load config", it) - writeConfig() - } - } - - private fun loadConfig() { - val configContent = readFileAction() - - val configObject: JsonObject = gson.fromJson( - configContent.toString(Charsets.UTF_8), - JsonObject::class.java - ) - - entries().forEach { (key, value) -> - value.writeFrom(configObject.get(key.name)?.asString ?: value.read()) - } - } - - fun writeConfig() { - val configObject = JsonObject() - entries().forEach { (key, value) -> - configObject.addProperty(key.name, value.read()) - } - writeFileAction(gson.toJson(configObject).toByteArray(Charsets.UTF_8)) - } - - fun loadFromContext(context: Context) { - val configFile = BridgeFileType.CONFIG.resolve(context) - isFileExistsAction = { configFile.exists() } - readFileAction = { - if (!configFile.exists()) { - configFile.createNewFile() - configFile.writeBytes("{}".toByteArray(Charsets.UTF_8)) - } - configFile.readBytes() - } - writeFileAction = { configFile.writeBytes(it) } - load() - } - - fun loadFromBridge(bridgeClient: AbstractBridgeClient) { - isFileExistsAction = { bridgeClient.isFileExists(BridgeFileType.CONFIG) } - readFileAction = { bridgeClient.createAndReadFile(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8)) } - writeFileAction = { bridgeClient.writeFile(BridgeFileType.CONFIG, it) } - load() - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.bridge + +import android.app.Activity +import android.os.Bundle + +class ForceStartActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + finish() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt @@ -1,70 +0,0 @@ -package me.rhunk.snapenhance.bridge - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.util.SQLiteDatabaseHelper -import java.io.File - -class MessageLoggerWrapper( - private val databaseFile: File -) { - - lateinit var database: SQLiteDatabase - - fun init() { - database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) - SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( - "messages" to listOf( - "id INTEGER PRIMARY KEY", - "conversation_id VARCHAR", - "message_id BIGINT", - "message_data BLOB" - ) - )) - } - - fun deleteMessage(conversationId: String, messageId: Long) { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - } - - fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - cursor.close() - if (state) { - return false - } - database.insert("messages", null, ContentValues().apply { - put("conversation_id", conversationId) - put("message_id", messageId) - put("message_data", serializedMessage) - }) - return true - } - - fun getMessage(conversationId: String, messageId: Long): Pair<Boolean, ByteArray?> { - val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - val message: ByteArray? = if (state) { - cursor.getBlob(0) - } else { - null - } - cursor.close() - return Pair(state, message) - } - - fun getMessageIds(conversationId: String, limit: Int): List<Long> { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? ORDER BY message_id DESC LIMIT ?", arrayOf(conversationId, limit.toString())) - val messageIds = mutableListOf<Long>() - while (cursor.moveToNext()) { - messageIds.add(cursor.getLong(0)) - } - cursor.close() - return messageIds - } - - fun clearMessages() { - database.execSQL("DELETE FROM messages") - } -}- \ 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 @@ -1,96 +0,0 @@ -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 @@ -1,155 +0,0 @@ -package me.rhunk.snapenhance.bridge.client - -import android.os.Environment -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.MessageLoggerWrapper -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.OutputStream -import java.util.zip.ZipInputStream - -class RootBridgeClient : AbstractBridgeClient() { - private lateinit var messageLoggerWrapper: MessageLoggerWrapper - companion object { - private val MOD_FOLDER = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),"SnapEnhance") - } - - override fun start(callback: (Boolean) -> Unit) { - if (!MOD_FOLDER.exists()) { - MOD_FOLDER.mkdirs() - } - messageLoggerWrapper = MessageLoggerWrapper(File(MOD_FOLDER, BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() } - callback(true) - } - - override fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray { - val file = File(MOD_FOLDER, fileType.fileName) - if (file.exists()) { - return readFile(fileType) - } - val outputStream = openFileWritable(file) - outputStream.write(defaultContent) - outputStream.close() - return defaultContent - } - - override fun readFile(fileType: BridgeFileType): ByteArray { - return File(MOD_FOLDER, fileType.fileName).readBytes() - } - - override fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean { - val outputStream = openFileWritable(File(MOD_FOLDER, fileType.fileName)) - outputStream.write(content) - outputStream.close() - return true - } - - override fun deleteFile(fileType: BridgeFileType): Boolean { - val file = File(MOD_FOLDER, fileType.fileName) - val exists = file.exists() - if (exists) { - rootOperation("rm ${file.absolutePath}") - } - return exists - } - - override fun isFileExists(fileType: BridgeFileType): Boolean { - return File(MOD_FOLDER, fileType.fileName).exists() - } - - override fun downloadContent(url: String, path: String): Boolean { - return true - } - - override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> { - return messageLoggerWrapper.getMessageIds(conversationId, limit) - } - - override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? { - val (state, messageData) = messageLoggerWrapper.getMessage(conversationId, id) - if (state) { - return messageData - } - return null - } - - 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(): LocaleResult { - val locale = "en_US"//Locale.getDefault().toString() - - //https://github.com/LSPosed/LSPosed/blob/master/core/src/main/java/org/lsposed/lspd/util/LspModuleClassLoader.java#L36 - val moduleApk = javaClass.classLoader.javaClass.declaredFields.first { it.type == String::class.java }.let { - it.isAccessible = true - it.get(javaClass.classLoader) as String - } - - val langJsonData: ByteArray? = ZipInputStream(FileInputStream(moduleApk)).let { zip -> - while (true) { - val entry = zip.nextEntry ?: break - if (entry.name == "assets/lang/$locale.json") { - return@let zip.readBytes() - } - } - return@let null - } - - if (langJsonData != null) { - Logger.debug("Fetched translations for $locale") - return LocaleResult(arrayOf(locale), arrayOf(langJsonData.toString(Charsets.UTF_8))) - } - - throw Throwable("Failed to fetch translations for $locale") - } - - override fun getAutoUpdaterTime(): Long { - readFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP).run { - return if (isEmpty()) { - 0 - } else { - String(this).toLong() - } - } - } - - override fun setAutoUpdaterTime(time: Long) { - writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray(Charsets.UTF_8)) - } - - private fun rootOperation(command: String): String { - val process = Runtime.getRuntime().exec("su -c $command") - process.waitFor() - process.errorStream?.bufferedReader()?.let { - val error = it.readText() - if (error.isNotEmpty()) { - throw Throwable("Failed to execute root operation: $error") - } - } - Logger.debug("Root operation executed: $command") - return process.inputStream.bufferedReader().readText() - } - - private fun openFileWritable(file: File): OutputStream { - runCatching { - if (!file.exists()) rootOperation("touch ${file.absolutePath}") - }.onFailure { - Logger.error("Failed to set file permissions: ${it.message}") - } - - return FileOutputStream(file) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt @@ -1,270 +0,0 @@ -package me.rhunk.snapenhance.bridge.client - - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.HandlerThread -import android.os.IBinder -import android.os.Message -import android.os.Messenger -import de.robv.android.xposed.XposedHelpers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.Logger.xposedLog -import me.rhunk.snapenhance.bridge.AbstractBridgeClient -import me.rhunk.snapenhance.bridge.common.BridgeMessage -import me.rhunk.snapenhance.bridge.common.BridgeMessageType -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult -import me.rhunk.snapenhance.bridge.service.BridgeService -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors -import kotlin.reflect.KClass -import kotlin.system.exitProcess - - -class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection { - private val handlerThread = HandlerThread("BridgeClient") - - private lateinit var messenger: Messenger - private lateinit var future: CompletableFuture<Boolean> - - override fun start(callback: (Boolean) -> Unit) { - this.future = CompletableFuture() - this.handlerThread.start() - - with(context.androidContext) { - val intent = Intent() - .setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - bindService( - intent, - Context.BIND_AUTO_CREATE, - Executors.newSingleThreadExecutor(), - this@ServiceBridgeClient - ) - } else { - XposedHelpers.callMethod( - this, - "bindServiceAsUser", - intent, - this@ServiceBridgeClient, - Context.BIND_AUTO_CREATE, - Handler(handlerThread.looper), - android.os.Process.myUserHandle() - ) - } - } - callback(future.get()) - } - - private fun handleResponseMessage( - msg: Message - ): BridgeMessage { - val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) { - BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult() - BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult() - BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult() - BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT -> MessageLoggerListResult() - BridgeMessageType.LOCALE_RESULT -> LocaleResult() - else -> throw IllegalStateException("Unknown message type: ${msg.what}") - } - - with(message) { - read(msg.data) - return this - } - } - - @Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") - private fun <T : BridgeMessage> sendMessage( - messageType: BridgeMessageType, - bridgeMessage: BridgeMessage, - resultType: KClass<T>? = null - ) = runBlocking { - return@runBlocking suspendCancellableCoroutine<T> { continuation -> - with(Message.obtain()) { - what = messageType.value - replyTo = Messenger(object : Handler(handlerThread.looper) { - override fun handleMessage(msg: Message) { - if (continuation.isCompleted) { - continuation.cancel(Throwable("Already completed")) - return - } - continuation.resumeWith(Result.success(handleResponseMessage(msg) as T)) - } - }) - data = Bundle() - bridgeMessage.write(data) - messenger.send(this) - } - } - } - - override fun createAndReadFile( - fileType: BridgeFileType, - defaultContent: ByteArray - ): ByteArray { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), - FileAccessResult::class - ).run { - if (state!!) { - return readFile(fileType) - } - writeFile(fileType, defaultContent) - return defaultContent - } - } - - override fun readFile(fileType: BridgeFileType): ByteArray { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null), - FileAccessResult::class - ).run { - return content!! - } - } - - override fun writeFile( - fileType: BridgeFileType, - content: ByteArray? - ): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content), - FileAccessResult::class - ).run { - return state!! - } - } - - override fun deleteFile(fileType: BridgeFileType): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null), - FileAccessResult::class - ).run { - return state!! - } - } - - - override fun isFileExists(fileType: BridgeFileType): Boolean { - sendMessage( - BridgeMessageType.FILE_ACCESS_REQUEST, - FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), - FileAccessResult::class - ).run { - return state!! - } - } - - override fun downloadContent(url: String, path: String): Boolean { - sendMessage( - BridgeMessageType.DOWNLOAD_CONTENT_REQUEST, - DownloadContentRequest(url, path), - DownloadContentResult::class - ).run { - return state!! - } - } - - override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.LIST_IDS, conversationId, limit.toLong()), - MessageLoggerListResult::class - ).run { - return messages!! - } - } - - override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.GET, conversationId, id), - MessageLoggerResult::class - ).run { - return message - } - } - - override fun addMessageLoggerMessage(conversationId: String,id: Long, message: ByteArray) { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.ADD, conversationId, id, message), - MessageLoggerResult::class - ) - } - - override fun deleteMessageLoggerMessage(conversationId: String,id: Long) { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.DELETE, conversationId, id), - MessageLoggerResult::class - ) - } - - override fun clearMessageLogger() { - sendMessage( - BridgeMessageType.MESSAGE_LOGGER_REQUEST, - MessageLoggerRequest(MessageLoggerRequest.Action.CLEAR), - MessageLoggerResult::class - ) - } - - override fun fetchTranslations(): LocaleResult { - sendMessage( - BridgeMessageType.LOCALE_REQUEST, - LocaleRequest(), - LocaleResult::class - ).run { - return this - } - } - - override fun getAutoUpdaterTime(): Long { - createAndReadFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, "0".toByteArray()).run { - return if (isEmpty()) { - 0 - } else { - String(this).toLong() - } - } - } - - override fun setAutoUpdaterTime(time: Long) { - writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray()) - } - - override fun onServiceConnected(name: ComponentName, service: IBinder) { - messenger = Messenger(service) - future.complete(true) - } - - override fun onNullBinding(name: ComponentName) { - xposedLog("failed to connect to bridge service") - future.complete(false) - } - - override fun onServiceDisconnected(name: ComponentName) { - exitProcess(0) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt @@ -1,16 +0,0 @@ -package me.rhunk.snapenhance.bridge.common - -import android.os.Bundle -import android.os.Message - -abstract class BridgeMessage { - abstract fun write(bundle: Bundle) - abstract fun read(bundle: Bundle) - - fun toMessage(what: Int): Message { - val message = Message.obtain(null, what) - message.data = Bundle() - write(message.data) - return message - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.bridge.common - - -enum class BridgeMessageType( - val value: Int = 0 -) { - UNKNOWN(-1), - FILE_ACCESS_REQUEST(0), - FILE_ACCESS_RESULT(1), - DOWNLOAD_CONTENT_REQUEST(2), - DOWNLOAD_CONTENT_RESULT(3), - LOCALE_REQUEST(4), - LOCALE_RESULT(5), - MESSAGE_LOGGER_REQUEST(6), - MESSAGE_LOGGER_RESULT(7), - MESSAGE_LOGGER_LIST_RESULT(8); - - companion object { - fun fromValue(value: Int): BridgeMessageType { - return values().firstOrNull { it.value == value } ?: UNKNOWN - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentRequest.kt @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.download - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class DownloadContentRequest( - var url: String? = null, - var path: String? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putString("url", url) - bundle.putString("path", path) - } - - override fun read(bundle: Bundle) { - url = bundle.getString("url") - path = bundle.getString("path") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/download/DownloadContentResult.kt @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.download - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class DownloadContentResult( - var state: Boolean? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/BridgeFileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/BridgeFileType.kt @@ -1,28 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - -import android.content.Context -import java.io.File - - -enum class BridgeFileType(val value: Int, val fileName: String, val displayName: String, val isDatabase: Boolean = false) { - CONFIG(0, "config.json", "Config"), - MAPPINGS(1, "mappings.json", "Mappings"), - MESSAGE_LOGGER_DATABASE(2, "message_logger.db", "Message Logger",true), - STEALTH(3, "stealth.txt", "Stealth Conversations"), - ANTI_AUTO_DOWNLOAD(4, "anti_auto_download.txt", "Anti Auto Download"), - ANTI_AUTO_SAVE(5, "anti_auto_save.txt", "Anti Auto Save"), - AUTO_UPDATER_TIMESTAMP(6, "auto_updater_timestamp.txt", "Auto Updater Timestamp"), - PINNED_CONVERSATIONS(7, "pinned_conversations.txt", "Pinned Conversations"); - - fun resolve(context: Context): File = if (isDatabase) { - context.getDatabasePath(fileName) - } else { - File(context.filesDir, fileName) - } - - companion object { - fun fromValue(value: Int): BridgeFileType? { - return values().firstOrNull { it.value == value } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessRequest.kt @@ -1,33 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class FileAccessRequest( - var action: FileAccessAction? = null, - var fileType: BridgeFileType? = null, - var content: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putInt("action", action!!.value) - bundle.putInt("fileType", fileType!!.value) - bundle.putByteArray("content", content) - } - - override fun read(bundle: Bundle) { - action = FileAccessAction.fromValue(bundle.getInt("action")) - fileType = BridgeFileType.fromValue(bundle.getInt("fileType")) - content = bundle.getByteArray("content") - } - - enum class FileAccessAction(val value: Int) { - READ(0), WRITE(1), DELETE(2), EXISTS(3); - - companion object { - fun fromValue(value: Int): FileAccessAction? { - return values().firstOrNull { it.value == value } - } - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/file/FileAccessResult.kt @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.file - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class FileAccessResult( - var state: Boolean? = null, - var content: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - bundle.putByteArray("content", content) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - content = bundle.getByteArray("content") - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/locale/LocaleRequest.kt @@ -1,12 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.locale - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class LocaleRequest() : BridgeMessage() { - override fun write(bundle: Bundle) { - } - - override fun read(bundle: Bundle) { - } -}- \ No newline at end of file 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 @@ -1,31 +0,0 @@ -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( - 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.putStringArray("locales", locales) - bundle.putSerializable("localContentArray", localContentArray) - } - - @Suppress("UNCHECKED_CAST", "DEPRECATION") - override fun read(bundle: Bundle) { - 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/common/impl/messagelogger/MessageLoggerListResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerListResult.kt @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - - -class MessageLoggerListResult( - var messages: List<Long>? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putLongArray("messages", messages!!.map { it }.toLongArray()) - } - - override fun read(bundle: Bundle) { - messages = bundle.getLongArray("messages")?.toList() ?: emptyList() - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerRequest.kt @@ -1,34 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class MessageLoggerRequest( - var action: Action? = null, - var conversationId: String? = null, - var index: Long? = null, - var message: ByteArray? = null -) : BridgeMessage(){ - - override fun write(bundle: Bundle) { - bundle.putString("action", action!!.name) - bundle.putString("conversationId", conversationId) - bundle.putLong("messageId", index ?: 0) - bundle.putByteArray("message", message) - } - - override fun read(bundle: Bundle) { - action = Action.valueOf(bundle.getString("action")!!) - conversationId = bundle.getString("conversationId") - index = bundle.getLong("messageId") - message = bundle.getByteArray("message") - } - - enum class Action { - ADD, - GET, - CLEAR, - DELETE, - LIST_IDS - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/messagelogger/MessageLoggerResult.kt @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.bridge.common.impl.messagelogger - -import android.os.Bundle -import me.rhunk.snapenhance.bridge.common.BridgeMessage - -class MessageLoggerResult( - var state: Boolean? = null, - var message: ByteArray? = null -) : BridgeMessage() { - - override fun write(bundle: Bundle) { - bundle.putBoolean("state", state!!) - bundle.putByteArray("message", message) - } - - override fun read(bundle: Bundle) { - state = bundle.getBoolean("state") - message = bundle.getByteArray("message") - } -}- \ 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 @@ -1,189 +0,0 @@ -package me.rhunk.snapenhance.bridge.service - -import android.annotation.SuppressLint -import android.app.DownloadManager -import android.app.Service -import android.content.* -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 -import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest -import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest -import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest -import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult -import java.io.File -import java.util.* - -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 Messenger(object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - runCatching { - this@BridgeService.handleMessage(msg) - }.onFailure { - Logger.error("Failed to handle message ${BridgeMessageType.fromValue(msg.what)}", it) - } - } - }).binder - } - - private fun handleMessage(msg: Message) { - val replyMessenger = msg.replyTo - when (BridgeMessageType.fromValue(msg.what)) { - BridgeMessageType.FILE_ACCESS_REQUEST -> { - with(FileAccessRequest()) { - read(msg.data) - handleFileAccess(this) { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.DOWNLOAD_CONTENT_REQUEST -> { - with(DownloadContentRequest()) { - read(msg.data) - handleDownloadContent(this) { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.LOCALE_REQUEST -> { - with(LocaleRequest()) { - read(msg.data) - handleLocaleRequest { message -> - replyMessenger.send(message) - } - } - } - BridgeMessageType.MESSAGE_LOGGER_REQUEST -> { - with(MessageLoggerRequest()) { - read(msg.data) - handleMessageLoggerRequest(this) { message -> - replyMessenger.send(message) - } - } - } - - else -> Logger.log("Unknown message type: " + msg.what) - } - } - - private fun handleMessageLoggerRequest(msg: MessageLoggerRequest, reply: (Message) -> Unit) { - when (msg.action) { - MessageLoggerRequest.Action.ADD -> { - val isSuccess = messageLoggerWrapper.addMessage(msg.conversationId!!, msg.index!!, msg.message!!) - reply(MessageLoggerResult(isSuccess).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - return - } - MessageLoggerRequest.Action.CLEAR -> { - messageLoggerWrapper.clearMessages() - } - MessageLoggerRequest.Action.DELETE -> { - messageLoggerWrapper.deleteMessage(msg.conversationId!!, msg.index!!) - } - MessageLoggerRequest.Action.GET -> { - val (state, messageData) = messageLoggerWrapper.getMessage(msg.conversationId!!, msg.index!!) - reply(MessageLoggerResult(state, messageData).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - return - } - MessageLoggerRequest.Action.LIST_IDS -> { - val messageIds = messageLoggerWrapper.getMessageIds(msg.conversationId!!, msg.index!!.toInt()) - reply(MessageLoggerListResult(messageIds).toMessage(BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT.value)) - return - } - else -> { - Logger.log(Exception("Unknown message logger action: ${msg.action}")) - } - } - - reply(MessageLoggerResult(true).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value)) - } - - private fun handleLocaleRequest(reply: (Message) -> Unit) { - val locales = sortedSetOf<String>() - val contentArray = sortedSetOf<String>() - - 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") - private fun handleDownloadContent(msg: DownloadContentRequest, reply: (Message) -> Unit) { - if (!msg.url!!.startsWith("http://127.0.0.1:")) return - - val outputFile = File(msg.path!!) - outputFile.parentFile?.let { - if (!it.exists()) it.mkdirs() - } - val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager - val request = DownloadManager.Request(Uri.parse(msg.url)) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - .setAllowedOverMetered(true) - .setAllowedOverRoaming(true) - .setDestinationUri(Uri.fromFile(outputFile)) - val downloadId = downloadManager.enqueue(request) - registerReceiver(object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return - unregisterReceiver(this) - reply(DownloadContentResult(true).toMessage(BridgeMessageType.DOWNLOAD_CONTENT_RESULT.value)) - } - }, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) - } - - private fun handleFileAccess(msg: FileAccessRequest, reply: (Message) -> Unit) { - val fileFolder = if (msg.fileType!!.isDatabase) { - File(dataDir, "databases") - } else { - File(filesDir.absolutePath) - } - val requestFile = File(fileFolder, msg.fileType!!.fileName) - - val result: FileAccessResult = when (msg.action) { - FileAccessRequest.FileAccessAction.READ -> { - if (!requestFile.exists()) { - FileAccessResult(false, null) - } else { - FileAccessResult(true, requestFile.readBytes()) - } - } - FileAccessRequest.FileAccessAction.WRITE -> { - if (!requestFile.exists()) { - requestFile.createNewFile() - } - requestFile.writeBytes(msg.content!!) - FileAccessResult(true, null) - } - FileAccessRequest.FileAccessAction.DELETE -> { - if (!requestFile.exists()) { - FileAccessResult(false, null) - } else { - requestFile.delete() - FileAccessResult(true, null) - } - } - FileAccessRequest.FileAccessAction.EXISTS -> FileAccessResult(requestFile.exists(), null) - else -> throw Exception("Unknown action: " + msg.action) - } - - reply(result.toMessage(BridgeMessageType.FILE_ACCESS_RESULT.value)) - } - -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/types/BridgeFileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/types/BridgeFileType.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.bridge.types + +import android.content.Context +import java.io.File + + +enum class BridgeFileType(val value: Int, val fileName: String, val displayName: String, private val isDatabase: Boolean = false) { + CONFIG(0, "config.json", "Config"), + MAPPINGS(1, "mappings.json", "Mappings"), + MESSAGE_LOGGER_DATABASE(2, "message_logger.db", "Message Logger",true), + STEALTH(3, "stealth.txt", "Stealth Conversations"), + ANTI_AUTO_DOWNLOAD(4, "anti_auto_download.txt", "Anti Auto Download"), + ANTI_AUTO_SAVE(5, "anti_auto_save.txt", "Anti Auto Save"), + AUTO_UPDATER_TIMESTAMP(6, "auto_updater_timestamp.txt", "Auto Updater Timestamp"), + PINNED_CONVERSATIONS(7, "pinned_conversations.txt", "Pinned Conversations"); + + fun resolve(context: Context): File = if (isDatabase) { + context.getDatabasePath(fileName) + } else { + File(context.filesDir, fileName) + } + + companion object { + fun fromValue(value: Int): BridgeFileType? { + return values().firstOrNull { it.value == value } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/ConfigWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/ConfigWrapper.kt @@ -0,0 +1,80 @@ +package me.rhunk.snapenhance.bridge.wrapper + +import android.content.Context +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.bridge.BridgeClient +import me.rhunk.snapenhance.bridge.types.BridgeFileType +import me.rhunk.snapenhance.config.ConfigAccessor +import me.rhunk.snapenhance.config.ConfigProperty + +class ConfigWrapper: ConfigAccessor() { + companion object { + private val gson = GsonBuilder().setPrettyPrinting().create() + } + + private lateinit var isFileExistsAction: () -> Boolean + private lateinit var writeFileAction: (ByteArray) -> Unit + private lateinit var readFileAction: () -> ByteArray + + fun load() { + ConfigProperty.sortedByCategory().forEach { key -> + set(key, key.valueContainer) + } + + if (!isFileExistsAction()) { + writeConfig() + return + } + + runCatching { + loadConfig() + }.onFailure { + Logger.error("Failed to load config", it) + writeConfig() + } + } + + private fun loadConfig() { + val configContent = readFileAction() + + val configObject: JsonObject = gson.fromJson( + configContent.toString(Charsets.UTF_8), + JsonObject::class.java + ) + + entries().forEach { (key, value) -> + value.writeFrom(configObject.get(key.name)?.asString ?: value.read()) + } + } + + fun writeConfig() { + val configObject = JsonObject() + entries().forEach { (key, value) -> + configObject.addProperty(key.name, value.read()) + } + writeFileAction(gson.toJson(configObject).toByteArray(Charsets.UTF_8)) + } + + fun loadFromContext(context: Context) { + val configFile = BridgeFileType.CONFIG.resolve(context) + isFileExistsAction = { configFile.exists() } + readFileAction = { + if (!configFile.exists()) { + configFile.createNewFile() + configFile.writeBytes("{}".toByteArray(Charsets.UTF_8)) + } + configFile.readBytes() + } + writeFileAction = { configFile.writeBytes(it) } + load() + } + + fun loadFromBridge(bridgeClient: BridgeClient) { + isFileExistsAction = { bridgeClient.isFileExists(BridgeFileType.CONFIG) } + readFileAction = { bridgeClient.createAndReadFile(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8)) } + writeFileAction = { bridgeClient.writeFile(BridgeFileType.CONFIG, it) } + load() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MessageLoggerWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MessageLoggerWrapper.kt @@ -0,0 +1,70 @@ +package me.rhunk.snapenhance.bridge.wrapper + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.util.SQLiteDatabaseHelper +import java.io.File + +class MessageLoggerWrapper( + private val databaseFile: File +) { + + lateinit var database: SQLiteDatabase + + fun init() { + database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) + SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( + "messages" to listOf( + "id INTEGER PRIMARY KEY", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_data BLOB" + ) + )) + } + + fun deleteMessage(conversationId: String, messageId: Long) { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } + + fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { + val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + val state = cursor.moveToFirst() + cursor.close() + if (state) { + return false + } + database.insert("messages", null, ContentValues().apply { + put("conversation_id", conversationId) + put("message_id", messageId) + put("message_data", serializedMessage) + }) + return true + } + + fun getMessage(conversationId: String, messageId: Long): Pair<Boolean, ByteArray?> { + val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + val state = cursor.moveToFirst() + val message: ByteArray? = if (state) { + cursor.getBlob(0) + } else { + null + } + cursor.close() + return Pair(state, message) + } + + fun getMessageIds(conversationId: String, limit: Int): List<Long> { + val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? ORDER BY message_id DESC LIMIT ?", arrayOf(conversationId, limit.toString())) + val messageIds = mutableListOf<Long>() + while (cursor.moveToNext()) { + messageIds.add(cursor.getLong(0)) + } + cursor.close() + return messageIds + } + + fun clearMessages() { + database.execSQL("DELETE FROM messages") + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/TranslationWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/TranslationWrapper.kt @@ -0,0 +1,97 @@ +package me.rhunk.snapenhance.bridge.wrapper + +import android.content.Context +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.bridge.BridgeClient +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: BridgeClient) { + bridgeClient.fetchTranslations().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/config/ConfigCategory.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt @@ -1,12 +1,14 @@ package me.rhunk.snapenhance.config enum class ConfigCategory( - val key: String + val key: String, + val hidden: Boolean = false ) { SPYING_PRIVACY("spying_privacy"), MEDIA_MANAGEMENT("media_manager"), UI_TWEAKS("ui_tweaks"), UPDATES("updates"), CAMERA("camera"), - EXPERIMENTAL_DEBUGGING("experimental_debugging"); + EXPERIMENTAL_DEBUGGING("experimental_debugging"), + DEVICE_SPOOFER("device_spoofer", hidden = true) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt @@ -1,18 +1,18 @@ package me.rhunk.snapenhance.config -import android.os.Environment import me.rhunk.snapenhance.config.impl.ConfigIntegerValue import me.rhunk.snapenhance.config.impl.ConfigStateListValue import me.rhunk.snapenhance.config.impl.ConfigStateSelection import me.rhunk.snapenhance.config.impl.ConfigStateValue import me.rhunk.snapenhance.config.impl.ConfigStringValue +import me.rhunk.snapenhance.data.NotificationType import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks -import java.io.File enum class ConfigProperty( val translationKey: String, val category: ConfigCategory, val valueContainer: ConfigValue<*>, + val valueContainerTranslationKey: String? = null, val shouldAppearInSettings: Boolean = true, val disableValueLocalization: Boolean = false ) { @@ -36,11 +36,12 @@ enum class ConfigProperty( "better_notifications", ConfigCategory.SPYING_PRIVACY, ConfigStateListValue( - listOf("snap", "chat", "reply_button"), + listOf("snap", "chat", "reply_button", "download_button"), mutableMapOf( "snap" to false, "chat" to false, - "reply_button" to false + "reply_button" to false, + "download_button" to false ) ) ), @@ -48,13 +49,10 @@ enum class ConfigProperty( "notification_blacklist", ConfigCategory.SPYING_PRIVACY, ConfigStateListValue( - listOf("snap", "chat", "typing"), - mutableMapOf( - "snap" to false, - "chat" to false, - "typing" to false - ) - ) + NotificationType.getIncomingValues().map { it.key }, + NotificationType.getIncomingValues().associate { it.key to false }.toMutableMap() + ), + valueContainerTranslationKey = "notifications", ), DISABLE_METRICS("disable_metrics", ConfigCategory.SPYING_PRIVACY, @@ -68,15 +66,14 @@ enum class ConfigProperty( ConfigCategory.SPYING_PRIVACY, ConfigStateValue(false) ), - PREVENT_SCREENSHOT_NOTIFICATIONS( - "prevent_screenshot_notifications", - ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) - ), - PREVENT_STATUS_NOTIFICATIONS( - "prevent_status_notifications", + PREVENT_SENDING_MESSAGES( + "prevent_sending_messages", ConfigCategory.SPYING_PRIVACY, - ConfigStateValue(false) + ConfigStateListValue( + NotificationType.getOutgoingValues().map { it.key }, + NotificationType.getOutgoingValues().associate { it.key to false }.toMutableMap() + ), + valueContainerTranslationKey = "notifications", ), ANONYMOUS_STORY_VIEW( "anonymous_story_view", @@ -93,10 +90,7 @@ enum class ConfigProperty( SAVE_FOLDER( "save_folder", ConfigCategory.MEDIA_MANAGEMENT, - ConfigStringValue(File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat", - "SnapEnhance" - ).absolutePath) + ConfigStringValue("", isFolderPath =true), ), AUTO_DOWNLOAD_OPTIONS( "auto_download_options", @@ -160,6 +154,19 @@ enum class ConfigProperty( ConfigCategory.MEDIA_MANAGEMENT, ConfigStateValue(false) ), + DOWNLOAD_LOGGING( + "download_logging", + ConfigCategory.MEDIA_MANAGEMENT, + ConfigStateListValue( + listOf("started", "success", "progress", "failure"), + mutableMapOf( + "started" to false, + "success" to true, + "progress" to false, + "failure" to true + ) + ) + ), //UI AND TWEAKS ENABLE_FRIEND_FEED_MENU_BAR( @@ -203,8 +210,9 @@ enum class ConfigProperty( "hide_story_section", ConfigCategory.UI_TWEAKS, ConfigStateListValue( - listOf("hide_friends", "hide_following", "hide_for_you"), + listOf("hide_friend_suggestions", "hide_friends", "hide_following", "hide_for_you"), mutableMapOf( + "hide_friend_suggestions" to false, "hide_friends" to false, "hide_following" to false, "hide_for_you" to false @@ -294,7 +302,11 @@ enum class ConfigProperty( "OFF" ) ), - + DISABLE_GOOGLE_PLAY_DIALOGS( + "disable_google_play_dialogs", + ConfigCategory.UI_TWEAKS, + ConfigStateValue(false) + ), //CAMERA CAMERA_DISABLE( @@ -376,8 +388,27 @@ enum class ConfigProperty( "unlimited_multi_snap", ConfigCategory.EXPERIMENTAL_DEBUGGING, ConfigStateValue(false) + ), + + //DEVICE SPOOFER + DEVICE_SPOOF( + "device_spoof", + ConfigCategory.DEVICE_SPOOFER, + ConfigStateValue(false) + ), + FINGERPRINT( + "device_fingerprint", + ConfigCategory.DEVICE_SPOOFER, + ConfigStringValue("") + ), + ANDROID_ID( + "android_id", + ConfigCategory.DEVICE_SPOOFER, + ConfigStringValue("") ); + fun getOptionTranslationKey(key: String) = "option.property.${valueContainerTranslationKey ?: translationKey}.$key" + companion object { fun sortedByCategory(): List<ConfigProperty> { return values().sortedBy { it.category.ordinal } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStringValue.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/impl/ConfigStringValue.kt @@ -4,6 +4,7 @@ import me.rhunk.snapenhance.config.ConfigValue class ConfigStringValue( private var value: String = "", + val isFolderPath: Boolean = false, val isHidden: Boolean = false ) : ConfigValue<String>() { override fun value() = value diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.data import java.io.File +import java.io.InputStream enum class FileType( val fileExtension: String? = null, @@ -58,5 +59,11 @@ enum class FileType( val hex = bytesToHex(headerBytes) return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN } + + fun fromInputStream(inputStream: InputStream): FileType { + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt @@ -4,39 +4,39 @@ enum class MessageState { PREPARING, SENDING, COMMITTED, FAILED, CANCELING } -enum class ChatMediaType ( - val value: Int +enum class NotificationType ( + val key: String, + val isIncoming: Boolean = false, + val associatedOutgoingContentType: ContentType? = null, ) { - IMAGE(0), - VIDEO(1), - VIDEO_NO_SOUND(2), - FRIEND_DEPRECATED(3), - BLOB(4), - LAGUNA_SOUND(5), - LAGUNA_NO_SOUND(6), - GIF(7), - FINGERPRINT_HEADER_SIZE(8), - AUDIO_STITCH(9), - PSYCHOMANTIS(10), - SCREAMINGMANTIS(11), - MALIBU_SOUND(12), - MALIBU_NO_SOUND(13), - LAGUNAHD_SOUND(14), - LAGUNAHD_NO_SOUND(15), - GHOSTMANTIS(16), - NEWPORT_SOUND(17), - NEWPORT_NO_SOUND(18), - AUDIO(19), - BLOOP(20), - SPECTACLES_IMAGE(21), - SPECTACLES_VIDEO(22), - SPECTACLES_VIDEO_NO_SOUND(23), - CHEERIOS_IMAGE(24), - CHEERIOS_VIDEO_SOUND(25), - CHEERIOS_VIDEO_NO_SOUND(26), - WEB(27), - UNRECOGNIZED_VALUE(-9999); + SCREENSHOT("chat_screenshot", true, ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT), + SCREEN_RECORD("chat_screen_record", true, ContentType.STATUS_CONVERSATION_CAPTURE_RECORD), + CAMERA_ROLL_SAVE("camera_roll_save", true, ContentType.STATUS_SAVE_TO_CAMERA_ROLL), + SNAP("snap",true), + CHAT("chat",true), + CHAT_REPLY("chat_reply",true), + TYPING("typing", true), + STORIES("stories",true), + INITIATE_AUDIO("initiate_audio",true), + ABANDON_AUDIO("abandon_audio", false, ContentType.STATUS_CALL_MISSED_AUDIO), + INITIATE_VIDEO("initiate_video",true), + ABANDON_VIDEO("abandon_video", false, ContentType.STATUS_CALL_MISSED_VIDEO); + + companion object { + fun getIncomingValues(): List<NotificationType> { + return values().filter { it.isIncoming }.toList() + } + + fun getOutgoingValues(): List<NotificationType> { + return values().filter { it.associatedOutgoingContentType != null }.toList() + } + + fun fromContentType(contentType: ContentType): NotificationType? { + return values().firstOrNull { it.associatedOutgoingContentType == contentType } + } + } } + enum class ContentType(val id: Int) { UNKNOWN(-1), SNAP(0), @@ -56,7 +56,8 @@ enum class ContentType(val id: Int) { CREATIVE_TOOL_ITEM(14), FAMILY_CENTER_INVITE(15), FAMILY_CENTER_ACCEPT(16), - FAMILY_CENTER_LEAVE(17); + FAMILY_CENTER_LEAVE(17), + STATUS_PLUS_GIFT(18); companion object { fun fromId(i: Int): ContentType { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt @@ -4,6 +4,7 @@ import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.setObjectField +@Suppress("UNCHECKED_CAST") class MessageDestinations(obj: Any) : AbstractWrapper(obj){ var conversations get() = (instanceNonNull().getObjectField("mConversations") as ArrayList<*>).map { SnapUUID(it) } set(value) = instanceNonNull().setObjectField("mConversations", value.map { it.instanceNonNull() }.toCollection(ArrayList())) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt @@ -2,83 +2,63 @@ 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.bridge.DownloadCallback +import me.rhunk.snapenhance.download.data.DashOptions +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.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 val uniqueHash: String? + private val metadata: DownloadMetadata, + private val callback: DownloadCallback ) { - 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) - putString("uniqueHash", uniqueHash) - }.apply(extras)) + 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)) + }) + }, callback) } fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { - sendToBroadcastReceiver( + enqueueDownloadRequest( DownloadRequest( - inputMedias = arrayOf(playlistUrl), - inputTypes = arrayOf(DownloadMediaType.REMOTE_MEDIA.name), + inputMedias = arrayOf(InputMedia( + content = playlistUrl, + type = DownloadMediaType.REMOTE_MEDIA + )), + dashOptions = DashOptions(offsetTime, duration), flags = DownloadRequest.Flags.IS_DASH_PLAYLIST ) - ) { - putBundle("dashOptions", Bundle().apply { - putString("offsetTime", offsetTime.toString()) - duration?.let { putString("duration", it.toString()) } - }) - } + ) } - fun downloadMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { - sendToBroadcastReceiver( + fun downloadSingleMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { + enqueueDownloadRequest( DownloadRequest( - inputMedias = arrayOf(mediaData), - inputTypes = arrayOf(mediaType.name), - mediaEncryption = if (encryption != null) mapOf(mediaData to encryption) else mapOf() + inputMedias = arrayOf(InputMedia( + content = mediaData, + type = mediaType, + encryption = encryption + )) ) ) } 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 - )) + original: InputMedia, + overlay: InputMedia, + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf(original, overlay), + flags = DownloadRequest.Flags.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 @@ -1,364 +0,0 @@ -package me.rhunk.snapenhance.download - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Handler -import android.widget.Toast -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.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.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 -) - -/** - * 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 - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { - if (coroutineContext.job.isCancelled) return - - runCatching { - val fileType = FileType.fromFile(inputFile) - if (fileType == FileType.UNKNOWN) { - longToast(translation.format("failed_gallery_toast", "error" to "Unknown media type")) - return - } - val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } - - inputFile.copyTo(outputFile, overwrite = true) - - pendingDownload.outputFile = outputFile.absolutePath - pendingDownload.downloadStage = DownloadStage.SAVED - - runCatching { - val contentUri = Uri.fromFile(outputFile) - val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") - mediaScanIntent.setData(contentUri) - context.sendBroadcast(mediaScanIntent) - }.onFailure { - Logger.error("Failed to scan media file", it) - longToast(translation.format("failed_gallery_toast", "error" to it.toString())) - } - - Logger.debug("download complete") - - //print the path of the saved media - val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { - if (!it.endsWith("/")) "$it/" else it - } - - shortToast( - translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", "")) - ) - }.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 - } - - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != DOWNLOAD_ACTION) return - this.context = context - Logger.debug("onReceive download") - - SharedContext.ensureInitialized(context) - - val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) - - SharedContext.downloadTaskManager.canDownloadMedia(downloadRequest.getUniqueHash())?.let { downloadStage -> - shortToast( - translation[if (downloadStage.isFinalStage) { - "already_downloaded_toast" - } else { - "already_queued_toast" - }] - ) - return - } - - CoroutineScope(Dispatchers.IO).launch { - 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() - 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 { - 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/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -0,0 +1,387 @@ +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.bridge.wrapper.ConfigWrapper +import me.rhunk.snapenhance.config.ConfigProperty +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 = ConfigWrapper().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.string(ConfigProperty.SAVE_FOLDER))) + ?: 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) } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.download import android.annotation.SuppressLint import android.content.Context import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.download.data.DownloadMetadata import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.enums.DownloadStage import me.rhunk.snapenhance.ui.download.MediaFilter @@ -35,42 +36,42 @@ class DownloadTaskManager { fun addTask(task: PendingDownload): Int { taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", arrayOf( - task.uniqueHash, - task.outputPath, + task.metadata.mediaIdentifier, + task.metadata.outputPath, task.outputFile, - task.mediaDisplayType, - task.mediaDisplaySource, - task.iconUrl, + task.metadata.mediaDisplayType, + task.metadata.mediaDisplaySource, + task.metadata.iconUrl, task.downloadStage.name ) ) - task.id = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { + task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { it.moveToFirst() it.getInt(0) } - pendingTasks[task.id] = task - return task.id + pendingTasks[task.downloadId] = task + return task.downloadId } fun updateTask(task: PendingDownload) { taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", arrayOf( - task.uniqueHash, - task.outputPath, + task.metadata.mediaIdentifier, + task.metadata.outputPath, task.outputFile, - task.mediaDisplayType, - task.mediaDisplaySource, - task.iconUrl, + task.metadata.mediaDisplayType, + task.metadata.mediaDisplaySource, + task.metadata.iconUrl, task.downloadStage.name, - task.id + task.downloadId ) ) //if the task is no longer active, move it to the cached tasks if (task.isJobActive()) { - pendingTasks[task.id] = task + pendingTasks[task.downloadId] = task } else { - pendingTasks.remove(task.id) - cachedTasks[task.id] = task + pendingTasks.remove(task.downloadId) + cachedTasks[task.downloadId] = task } } @@ -107,20 +108,20 @@ class DownloadTaskManager { } fun removeTask(task: PendingDownload) { - removeTask(task.id) + removeTask(task.downloadId) } fun queryAllTasks(filter: MediaFilter): Map<Int, PendingDownload> { val isPendingFilter = filter == MediaFilter.PENDING val tasks = mutableMapOf<Int, PendingDownload>() - tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.mediaDisplayType) }) + tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) }) if (isPendingFilter) { return tasks.toSortedMap(reverseOrder()) } tasks.putAll(queryTasks( - from = tasks.values.lastOrNull()?.id ?: Int.MAX_VALUE, + from = tasks.values.lastOrNull()?.downloadId ?: Int.MAX_VALUE, amount = 30, filter = filter )) @@ -147,13 +148,15 @@ class DownloadTaskManager { while (cursor.moveToNext()) { val task = PendingDownload( - id = cursor.getInt(cursor.getColumnIndex("id")), + downloadId = cursor.getInt(cursor.getColumnIndex("id")), outputFile = cursor.getString(cursor.getColumnIndex("outputFile")), - outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), - mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), - mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), - uniqueHash = cursor.getString(cursor.getColumnIndex("hash")), - iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) + metadata = DownloadMetadata( + outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), + mediaIdentifier = cursor.getString(cursor.getColumnIndex("hash")), + mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), + mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), + iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) + ) ).apply { downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) //if downloadStage is not saved, it means the app was killed before the download was finished @@ -161,7 +164,7 @@ class DownloadTaskManager { downloadStage = DownloadStage.FAILED } } - result[task.id] = task + result[task.downloadId] = task } cursor.close() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMetadata.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMetadata.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.download.data + +data class DownloadMetadata( + val mediaIdentifier: String?, + val outputPath: String, + val mediaDisplaySource: String?, + val mediaDisplayType: String?, + val iconUrl: String? +)+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.download.data -import android.os.Bundle import me.rhunk.snapenhance.download.enums.DownloadMediaType @@ -12,64 +11,12 @@ data class InputMedia( ) class DownloadRequest( - private val outputPath: String = "", - private val inputMedias: Array<String>, - private val inputTypes: Array<String>, - private val mediaEncryption: Map<String, MediaEncryptionKeyPair> = emptyMap(), + val inputMedias: Array<InputMedia>, + val dashOptions: DashOptions? = null, private val flags: Int = 0, - private val dashOptions: Map<String, String?>? = null, - private val mediaDisplaySource: String? = null, - private val mediaDisplayType: String? = null, - private val uniqueHash: String? = null ) { - companion object { - fun fromBundle(bundle: Bundle): DownloadRequest { - return DownloadRequest( - outputPath = bundle.getString("outputPath")!!, - mediaDisplaySource = bundle.getString("mediaDisplaySource"), - mediaDisplayType = bundle.getString("mediaDisplayType"), - inputMedias = bundle.getStringArray("inputMedias")!!, - inputTypes = bundle.getStringArray("inputTypes")!!, - mediaEncryption = bundle.getStringArray("mediaEncryption")?.associate { entry -> - entry.split("|").let { - it[0] to MediaEncryptionKeyPair(it[1], it[2]) - } - } ?: emptyMap(), - dashOptions = bundle.getBundle("dashOptions")?.let { options -> - options.keySet().associateWith { key -> - options.getString(key) - } - }, - flags = bundle.getInt("flags", 0), - uniqueHash = bundle.getString("uniqueHash") - ) - } - } - - fun toBundle(): Bundle { - return Bundle().apply { - putString("outputPath", outputPath) - putString("mediaDisplaySource", mediaDisplaySource) - putString("mediaDisplayType", mediaDisplayType) - putStringArray("inputMedias", inputMedias) - putStringArray("inputTypes", inputTypes) - putStringArray("mediaEncryption", mediaEncryption.map { entry -> - "${entry.key}|${entry.value.key}|${entry.value.iv}" - }.toTypedArray()) - putBundle("dashOptions", dashOptions?.let { bundle -> - Bundle().apply { - bundle.forEach { (key, value) -> - putString(key, value) - } - } - }) - putInt("flags", flags) - putString("uniqueHash", uniqueHash) - } - } - object Flags { - const val SHOULD_MERGE_OVERLAY = 1 + const val MERGE_OVERLAY = 1 const val IS_DASH_PLAYLIST = 2 } @@ -77,30 +24,5 @@ class DownloadRequest( get() = flags and Flags.IS_DASH_PLAYLIST != 0 val shouldMergeOverlay: Boolean - get() = flags and Flags.SHOULD_MERGE_OVERLAY != 0 - - fun getDashOptions(): DashOptions? { - return dashOptions?.let { - DashOptions( - offsetTime = it["offsetTime"]?.toLong() ?: 0, - duration = it["duration"]?.toLong() - ) - } - } - - fun getInputMedias(): List<InputMedia> { - return inputMedias.mapIndexed { index, uri -> - InputMedia( - content = uri, - type = DownloadMediaType.valueOf(inputTypes[index]), - encryption = mediaEncryption.getOrDefault(uri, null) - ) - } - } - - fun getInputType(index: Int): DownloadMediaType? { - return inputTypes.getOrNull(index)?.let { DownloadMediaType.valueOf(it) } - } - - fun getUniqueHash() = uniqueHash + get() = flags and Flags.MERGE_OVERLAY != 0 } \ 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 @@ -1,33 +1,16 @@ package me.rhunk.snapenhance.download.data -import android.os.Bundle import kotlinx.coroutines.Job import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.download.enums.DownloadStage data class PendingDownload( + var downloadId: Int = 0, var outputFile: String? = null, var job: Job? = null, - var id: Int = 0, - val outputPath: String, - val mediaDisplayType: String?, - val mediaDisplaySource: String?, - val iconUrl: String?, - val uniqueHash: String? + val metadata : DownloadMetadata ) { - companion object { - fun fromBundle(bundle: Bundle): PendingDownload { - return PendingDownload( - outputPath = bundle.getString("outputPath")!!, - mediaDisplayType = bundle.getString("mediaDisplayType"), - mediaDisplaySource = bundle.getString("mediaDisplaySource"), - iconUrl = bundle.getString("iconUrl"), - uniqueHash = bundle.getString("uniqueHash") - ) - } - } - var changeListener = { _: DownloadStage, _: DownloadStage -> } private var _stage: DownloadStage = DownloadStage.PENDING var downloadStage: DownloadStage diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/SplitMediaAssetType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/SplitMediaAssetType.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.download.data + +enum class SplitMediaAssetType { + ORIGINAL, OVERLAY +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import java.io.BufferedReader import java.io.ByteArrayInputStream import java.io.InputStreamReader diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt @@ -28,4 +28,8 @@ abstract class Feature( * called on a dedicated thread when the Snapchat Activity is created */ open fun asyncOnActivityCreate() {} + + protected fun findClass(name: String): Class<*> { + return context.androidContext.classLoader.loadClass(name) + } } \ No newline at end of file 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 @@ -14,6 +14,7 @@ import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONArray @@ -61,7 +62,7 @@ class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVI val downloadEndpoint = latestRelease.getJSONArray("assets").getJSONObject(0).getString("browser_download_url") context.runOnUiThread { - AlertDialog.Builder(context.mainActivity) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(context.translation["auto_updater.dialog_title"]) .setMessage( context.translation.format("auto_updater.dialog_message", diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.features.impl +import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.features.Feature @@ -13,9 +14,9 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C lateinit var conversationManager: Any var openedConversationUUID: SnapUUID? = null - var lastOpenedConversationUUID: SnapUUID? = null var lastFetchConversationUserUUID: SnapUUID? = null var lastFetchConversationUUID: SnapUUID? = null + var lastFetchGroupConversationUUID: SnapUUID? = null var lastFocusedMessageId: Long = -1 override fun init() { @@ -25,6 +26,17 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } override fun onActivityCreate() { + context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> + findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> + val viewItemContainer = param.arg<Any>(0) + val viewItem = viewItemContainer.getObjectField(mappings["viewModelField"].toString()).toString() + val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also { + if (it.startsWith("null")) return@hook + } + lastFetchGroupConversationUUID = SnapUUID.fromString(conversationId) + } + } + context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param -> val userIdToConversation = (param.arg<ArrayList<*>>(0)) .takeIf { it.isNotEmpty() } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/AntiAutoDownload.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/AntiAutoDownload.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features.impl.downloader -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams 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 @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.features.impl.downloader -import android.app.AlertDialog import android.content.DialogInterface import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -9,10 +8,13 @@ import android.widget.ImageView import com.arthenica.ffmpegkit.FFmpegKit import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH +import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger.xposedLog +import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem @@ -20,6 +22,9 @@ 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.DownloadManagerClient +import me.rhunk.snapenhance.download.data.DownloadMetadata +import me.rhunk.snapenhance.download.data.InputMedia +import me.rhunk.snapenhance.download.data.SplitMediaAssetType import me.rhunk.snapenhance.download.data.toKeyPair import me.rhunk.snapenhance.download.enums.DownloadMediaType import me.rhunk.snapenhance.features.Feature @@ -29,6 +34,7 @@ import me.rhunk.snapenhance.features.impl.spying.MessageLogger import me.rhunk.snapenhance.hook.HookAdapter import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.download.MediaFilter import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.getObjectField @@ -36,20 +42,17 @@ import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.snap.BitmojiSelfie import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.util.snap.MediaType import me.rhunk.snapenhance.util.snap.PreviewUtils -import java.io.File import java.nio.file.Paths import java.text.SimpleDateFormat import java.util.Locale import kotlin.coroutines.suspendCoroutine import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.io.path.inputStream @OptIn(ExperimentalEncodingApi::class) class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null + private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null private var lastSeenMapParams: ParamMap? = null private val isFFmpegPresent by lazy { runCatching { FFmpegKit.execute("-version") }.isSuccess @@ -70,22 +73,47 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam BitmojiSelfie.getBitmojiSelfie(it.bitmojiSelfieId!!, it.bitmojiAvatarId!!, BitmojiSelfie.BitmojiSelfieType.THREE_D) } - val outputPath = File( - context.config.string(ConfigProperty.SAVE_FOLDER), - createNewFilePath(generatedHash, mediaDisplayType, pathSuffix) - ).absolutePath + val downloadLogging = context.config.options(ConfigProperty.DOWNLOAD_LOGGING) + if (downloadLogging["started"] == true) { + context.shortToast(context.translation["download_processor.download_started_toast"]) + } + + val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix) return DownloadManagerClient( context = context, - mediaDisplaySource = mediaDisplaySource, - mediaDisplayType = mediaDisplayType, - iconUrl = iconUrl, - uniqueHash = - // If duplicate is allowed, we don't need to pass the hash - if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["allow_duplicate"] == false) { - generatedHash - } else null, - outputPath = outputPath + metadata = DownloadMetadata( + mediaIdentifier = if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["allow_duplicate"] == false) { + generatedHash + } else null, + mediaDisplaySource = mediaDisplaySource, + mediaDisplayType = mediaDisplayType, + iconUrl = iconUrl, + outputPath = outputPath + ), + callback = object: DownloadCallback.Stub() { + override fun onSuccess(outputFile: String) { + if (downloadLogging["success"] != true) return + Logger.debug("onSuccess: outputFile=$outputFile") + context.shortToast(context.translation.format("download_processor.saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) + } + + override fun onProgress(message: String) { + if (downloadLogging["progress"] != true) return + Logger.debug("onProgress: message=$message") + context.shortToast(message) + } + + override fun onFailure(message: String, throwable: String?) { + if (downloadLogging["failure"] != true) return + Logger.debug("onFailure: message=$message, throwable=$throwable") + throwable?.let { + context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) + return + } + context.shortToast(message) + } + } ) } @@ -162,28 +190,31 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } - private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<MediaType, MediaInfo>) { + private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) { if (mediaInfoMap.isEmpty()) return - val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! - val overlay = mediaInfoMap[MediaType.OVERLAY] - + val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) - val overlayReference = overlay?.let { handleLocalReferences(it.uri) } - overlay?.let { + mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> + val overlayReference = handleLocalReferences(overlay.uri) + downloadManagerClient.downloadMediaWithOverlay( - originalMediaInfoReference, - overlayReference!!, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - DownloadMediaType.fromUri(Uri.parse(overlayReference)), - videoEncryption = originalMediaInfo.encryption?.toKeyPair(), - overlayEncryption = overlay.encryption?.toKeyPair() + original = InputMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ), + overlay = InputMedia( + overlayReference, + DownloadMediaType.fromUri(Uri.parse(overlayReference)), + overlay.encryption?.toKeyPair() + ) ) return } - downloadManagerClient.downloadMedia( + downloadManagerClient.downloadSingleMedia( originalMediaInfoReference, DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), originalMediaInfo.encryption?.toKeyPair() @@ -199,7 +230,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam */ private fun handleOperaMedia( paramMap: ParamMap, - mediaInfoMap: Map<MediaType, MediaInfo>, + mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, forceDownload: Boolean ) { //messages @@ -229,14 +260,32 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } //private stories - paramMap["PLAYLIST_V2_GROUP"]?.toString()?.takeIf { - it.contains("storyUserId=") && (forceDownload || canAutoDownload("friend_stories")) + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { + forceDownload || canAutoDownload("friend_stories") }?.let { playlistGroup -> - val storyUserId = (playlistGroup.indexOf("storyUserId=") + 12).let { - playlistGroup.substring(it, playlistGroup.indexOf(",", it)) + val playlistGroupString = playlistGroup.toString() + + val storyUserId = if (playlistGroupString.contains("storyUserId=")) { + (playlistGroupString.indexOf("storyUserId=") + 12).let { + playlistGroupString.substring(it, playlistGroupString.indexOf(",", it)) + } + } else { + //story replies + val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } + ?.invoke(playlistGroup)?.toString() + ?.split(":")?.getOrNull(2) ?: return@let + + val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let + val conversationParticipants = context.database.getConversationParticipants(conversationMessage.client_conversation_id.toString()) ?: return@let + + conversationParticipants.firstOrNull { it != conversationMessage.sender_id } } - val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) ?: return + val author = context.database.getFriendInfo( + if (storyUserId == null || storyUserId == "null") + context.database.getMyUserId()!! + else storyUserId + ) ?: throw Exception("Friend not found in database") val authorName = author.usernameForSorting!! downloadOperaMedia(provideClientDownloadManager( @@ -333,7 +382,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } override fun asyncOnActivityCreate() { - val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class") + val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "class") val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> @@ -347,13 +396,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) return@onOperaViewStateCallback - val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>() + val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() val isVideo = mediaParamMap.containsKey("video_media_info_list") - mediaInfoMap[MediaType.ORIGINAL] = MediaInfo( + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! ) if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[MediaType.OVERLAY] = + mediaInfoMap[SplitMediaAssetType.OVERLAY] = MediaInfo(mediaParamMap["overlay_image_media_info"]!!) } lastSeenMapParams = mediaParamMap @@ -371,7 +420,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } - arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName -> + arrayOf("onDisplayStateChange", "onDisplayStateChangeGesture").forEach { methodName -> Hooker.hook( operaViewerControllerClass, context.mappings.getMappedValue("OperaPageViewController", methodName), @@ -380,20 +429,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } - /** - * Called when a message is focused in chat - */ - //TODO: use snapchat classes instead of database (when content is deleted) - fun onMessageActionMenu(isPreviewMode: Boolean) { - //check if the message was focused in a conversation - val messaging = context.feature(Messaging::class) + fun downloadMessageId(messageId: Long, isPreview: Boolean = false) { val messageLogger = context.feature(MessageLogger::class) - - if (messaging.openedConversationUUID == null) return - val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return + val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") //get the message author - val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!)!! + val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!) ?: throw Exception("Friend not found in database") val authorName = friendInfo.usernameForSorting!! var messageContent = message.message_content!! @@ -425,10 +466,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } + val translations = context.translation.getCategory("download_processor") + if (contentType != ContentType.NOTE && contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) { - context.shortToast("Unsupported content type $contentType") + context.shortToast(translations["unsupported_content_type_toast"]) return } @@ -440,7 +483,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } runCatching { - if (!isPreviewMode) { + if (!isPreview) { val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) provideClientDownloadManager( pathSuffix = authorName, @@ -448,7 +491,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam mediaDisplaySource = authorName, mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType, friendInfo = friendInfo - ).downloadMedia( + ).downloadSingleMedia( Base64.UrlSafe.encode(urlProto), DownloadMediaType.PROTO_MEDIA, encryption = encryptionKeys?.toKeyPair() @@ -456,18 +499,23 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam return } + if (EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) == null) { + context.shortToast(translations["failed_no_longer_available_toast"]) + return + } + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) { EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage) } runCatching { - val originalMedia = downloadedMediaList[MediaType.ORIGINAL] ?: return - val overlay = downloadedMediaList[MediaType.OVERLAY] + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) if (bitmap == null) { - context.shortToast("Failed to create preview") + context.shortToast(translations["failed_to_create_preview_toast"]) return } @@ -475,21 +523,29 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) } - with(AlertDialog.Builder(context.mainActivity)) { - setTitle("Preview") + with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { setView(ImageView(context).apply { setImageBitmap(bitmap) }) setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() } - this@MediaDownloader.context.runOnUiThread { show() } + this@MediaDownloader.context.runOnUiThread { show()} } }.onFailure { - context.shortToast("Failed to create preview: $it") + context.shortToast(translations["failed_to_create_preview_toast"]) xposedLog(it) } }.onFailure { - context.longToast("Failed to download " + it.message) + context.longToast(translations["failed_generic_toast"]) xposedLog(it) } } + + /** + * Called when a message is focused in chat + */ + fun onMessageActionMenu(isPreviewMode: Boolean) { + val messaging = context.feature(Messaging::class) + if (messaging.openedConversationUUID == null) return + downloadMessageId(messaging.lastFocusedMessageId, isPreviewMode) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt @@ -12,6 +12,7 @@ import android.widget.EditText import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.ui.ViewAppearanceHelper //TODO: fingerprint unlock class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { @@ -32,7 +33,7 @@ class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIV val mainActivity = context.mainActivity!! setActivityVisibility(false) - val prompt = AlertDialog.Builder(mainActivity) + val prompt = ViewAppearanceHelper.newAlertDialogBuilder(mainActivity) val createPrompt = { val alertDialog = prompt.create() val textView = EditText(mainActivity) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.features.impl.experiments + +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker + +class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + //FINGERPRINT + if(getFingerprint().isNotEmpty()) { + val fingerprintClass = android.os.Build::class.java + Hooker.hook(fingerprintClass, "FINGERPRINT", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(getFingerprint()) + } + Hooker.hook(fingerprintClass, "deriveFingerprint", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(getFingerprint()) + } + } + else { + Logger.xposedLog("Fingerprint is null, not spoofing") + } + + //ANDROID ID + if(getAndroidId().isNotEmpty()) { + val settingsSecureClass = android.provider.Settings.Secure::class.java + Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> + if(hookAdapter.args()[1] == "android_id") { + hookAdapter.setResult(getAndroidId()) + } + } + } + else { + Logger.xposedLog("Android ID is null, not spoofing") + } + } + private fun getFingerprint():String { + return context.config.string(ConfigProperty.FINGERPRINT) + } + + private fun getAndroidId():String { + return context.config.string(ConfigProperty.ANDROID_ID) + } + +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt @@ -8,9 +8,11 @@ import me.rhunk.snapenhance.hook.Hooker class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { override fun asyncOnActivityCreate() { + val bcrypt = context.mappings.getMappedMap("BCrypt") + Hooker.hook( - context.mappings.getMappedClass("BCryptClass"), - context.mappings.getMappedValue("BCryptClassHashMethod"), + context.androidContext.classLoader.loadClass(bcrypt["class"].toString()), + bcrypt["hashMethod"].toString(), HookStage.BEFORE, { context.config.bool(ConfigProperty.MEO_PASSCODE_BYPASS) }, ) { param -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt @@ -1,36 +1,39 @@ package me.rhunk.snapenhance.features.impl.privacy +import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.NotificationType import me.rhunk.snapenhance.data.wrapper.impl.MessageContent import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook -class PreventMessageSending : Feature("Send message override", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { +class PreventMessageSending : Feature("Prevent message sending", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { override fun asyncOnActivityCreate() { - Hooker.hook( - context.classCache.conversationManager, - "sendMessageWithContent", - HookStage.BEFORE - ) { param -> - val message = MessageContent(param.arg(1)) - val contentType = message.contentType + context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> + val messageUpdate = param.arg<Any>(2).toString(); + val options by lazy { + context.config.options(ConfigProperty.PREVENT_SENDING_MESSAGES) + } + if (messageUpdate == "SCREENSHOT" && options["chat_screenshot"] == true) { + param.setResult(null) + } - if (context.config.bool(ConfigProperty.PREVENT_STATUS_NOTIFICATIONS)) { - if (contentType == ContentType.STATUS_SAVE_TO_CAMERA_ROLL || - contentType == ContentType.STATUS_CALL_MISSED_AUDIO || - contentType == ContentType.STATUS_CALL_MISSED_VIDEO) { - param.setResult(null) - } + if (messageUpdate == "SCREEN_RECORD" && options["chat_screen_record"] == true) { + param.setResult(null) } + } + + context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param -> + val message = MessageContent(param.arg(1)) + val contentType = message.contentType + val associatedType = NotificationType.fromContentType(contentType) ?: return@hook + val options = context.config.options(ConfigProperty.PREVENT_SENDING_MESSAGES) - if (context.config.bool(ConfigProperty.PREVENT_SCREENSHOT_NOTIFICATIONS)) { - if (contentType == ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT || - contentType == ContentType.STATUS_CONVERSATION_CAPTURE_RECORD) { - param.setResult(null) - } + if (options[associatedType.key] == true) { + Logger.debug("Preventing message sending for $associatedType") + param.setResult(null) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.features.impl.spying +import android.os.DeadObjectException import com.google.gson.JsonObject import com.google.gson.JsonParser import me.rhunk.snapenhance.Logger @@ -11,6 +12,7 @@ import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import java.util.concurrent.Executors import kotlin.time.ExperimentalTime import kotlin.time.measureTime @@ -23,6 +25,8 @@ class MessageLogger : Feature("MessageLogger", const val PREFETCH_FEED_COUNT = 20 } + private val threadPool = Executors.newFixedThreadPool(10) + //two level of cache to avoid querying the database private val fetchedMessages = mutableListOf<Long>() private val deletedMessageCache = mutableMapOf<Long, JsonObject>() @@ -59,7 +63,7 @@ class MessageLogger : Feature("MessageLogger", measureTime { context.database.getFriendFeed(PREFETCH_FEED_COUNT).forEach { friendFeedInfo -> - fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT)) + fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList()) } }.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") } } @@ -79,11 +83,13 @@ class MessageLogger : Feature("MessageLogger", if (fetchedMessages.contains(messageId)) return fetchedMessages.add(messageId) - context.executeAsync { - context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let { - return@executeAsync - } - context.bridgeClient.addMessageLoggerMessage(conversationId, messageId, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) + threadPool.execute { + try { + context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let { + return@execute + } + context.bridgeClient.addMessageLoggerMessage(conversationId, messageId, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) + } catch (ignored: DeadObjectException) {} } return diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features.impl.spying -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AntiAutoSave.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AntiAutoSave.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features.impl.tweaks -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt @@ -9,6 +9,7 @@ import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.util.protobuf.ProtoReader class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { @@ -24,7 +25,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara if (messageProtoReader.readPath(3)?.getCount(3) != 1) { context.runOnUiThread { - AlertDialog.Builder(context.mainActivity!!) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) .setMessage("You can only send one media at a time") .setPositiveButton("OK", null) .show() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.features.impl.tweaks + +import android.app.AlertDialog +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.hook +import java.lang.reflect.Modifier + +class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + if (!context.config.bool(ConfigProperty.DISABLE_GOOGLE_PLAY_DIALOGS)) return + + findClass("com.google.android.gms.common.GoogleApiAvailability").methods + .first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method -> + method.hook(HookStage.BEFORE) { param -> + Logger.debug("GoogleApiAvailability.showErrorDialogFragment() called, returning null") + param.setResult(null) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt @@ -4,14 +4,15 @@ import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook class MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParams = FeatureLoadParams.INIT_SYNC) { override fun init() { - val enumQualityLevel = context.mappings.getMappedClass("enums", "QualityLevel") + val enumQualityLevel = context.mappings.getMappedClass("EnumQualityLevel") + val mediaQualityLevelProvider = context.mappings.getMappedMap("MediaQualityLevelProvider") - Hooker.hook(context.mappings.getMappedClass("MediaQualityLevelProvider"), - context.mappings.getMappedValue("MediaQualityLevelProviderMethod"), + context.androidContext.classLoader.loadClass(mediaQualityLevelProvider["class"].toString()).hook( + mediaQualityLevelProvider["method"].toString(), HookStage.BEFORE, { context.config.bool(ConfigProperty.FORCE_MEDIA_SOURCE_QUALITY) } ) { param -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -19,21 +19,24 @@ import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MediaReferenceType import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.download.data.SplitMediaAssetType import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.util.CallbackBuilder +import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.util.snap.MediaType import me.rhunk.snapenhance.util.snap.PreviewUtils -import me.rhunk.snapenhance.util.protobuf.ProtoReader class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { companion object{ - const val ACTION_REPLY = "me.rhunk.snapenhance.action.REPLY" + const val ACTION_REPLY = "me.rhunk.snapenhance.action.notification.REPLY" + const val ACTION_DOWNLOAD = "me.rhunk.snapenhance.action.notification.DOWNLOAD" } private val notificationDataQueue = mutableMapOf<Long, NotificationData>() // messageId => notification @@ -54,10 +57,6 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN ) } - private val cancelAsUserMethod by lazy { - XposedHelpers.findMethodExact(NotificationManager::class.java, "cancelAsUser", String::class.java, Int::class.javaPrimitiveType, UserHandle::class.java) - } - private val fetchConversationWithMessagesMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"} } @@ -66,51 +65,69 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } - private fun setNotificationText(notification: Notification, text: String) { + private fun setNotificationText(notification: Notification, conversationId: String) { + val messageText = StringBuilder().apply { + cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { + if (isNotEmpty()) append("\n") + append(it) + } + }.toString() + with(notification.extras) { - putString("android.text", text) - putString("android.bigText", text) + putString("android.text", messageText) + putString("android.bigText", messageText) + putParcelableArray("android.messages", messageText.split("\n").map { + Bundle().apply { + putBundle("extras", Bundle()) + putString("text", it) + putLong("time", System.currentTimeMillis()) + } + }.toTypedArray()) } } - private fun computeNotificationText(conversationId: String): String { - val messageBuilder = StringBuilder() - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { - if (messageBuilder.isNotEmpty()) messageBuilder.append("\n") - messageBuilder.append(it) - } - return messageBuilder.toString() - } + private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, messageId: Long, notificationData: NotificationData) { + val betterNotifications = context.config.options(ConfigProperty.BETTER_NOTIFICATIONS) - private fun setupNotificationActionButtons(conversationId: String, notificationData: NotificationData) { val notificationBuilder = XposedHelpers.newInstance( Notification.Builder::class.java, context.androidContext, notificationData.notification ) as Notification.Builder - val chatReplyInput = RemoteInput.Builder("chat_reply_input") - .setLabel("Reply") - .build() - - val replyIntent = Intent() - .setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name) - .putExtra("conversation_id", conversationId) - .putExtra("notification_id", notificationData.id) - .setAction(ACTION_REPLY) - - val action = Notification.Action.Builder( - null, - "Reply", - PendingIntent.getBroadcast( + val actions = mutableListOf<Notification.Action>() + actions.addAll(notificationData.notification.actions) + + fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { + if (!filter()) return + val intent = Intent().setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name) + .putExtra("conversation_id", conversationId) + .putExtra("notification_id", notificationData.id) + .putExtra("message_id", messageId) + .setAction(remoteAction) + val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( context.androidContext, System.nanoTime().toInt(), - replyIntent, + intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - ) - ).addRemoteInput(chatReplyInput).build() + )).apply(builder).build() + actions.add(action) + } - notificationBuilder.setActions(action) + newAction("Reply", ACTION_REPLY, { + betterNotifications["reply_button"] == true && contentType == ContentType.CHAT + }) { + val chatReplyInput = RemoteInput.Builder("chat_reply_input") + .setLabel("Reply") + .build() + it.addRemoteInput(chatReplyInput) + } + + newAction("Download", ACTION_DOWNLOAD, { + betterNotifications["download_button"] == true && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP) + }) {} + + notificationBuilder.setActions(*actions.toTypedArray()) notificationData.notification = notificationBuilder.build() } @@ -118,12 +135,14 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN Hooker.hook(broadcastReceiverClass, "onReceive", HookStage.BEFORE) { param -> val androidContext = param.arg<Context>(0) val intent = param.arg<Intent>(1) - if (intent.action != ACTION_REPLY) return@hook - param.setResult(null) + val conversationId = intent.getStringExtra("conversation_id") ?: return@hook + val messageId = intent.getLongExtra("message_id", -1) + val notificationId = intent.getIntExtra("notification_id", -1) val notificationManager = androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val updateNotification: (Int, (Notification) -> Unit) -> Unit = { notificationId, notificationBuilder -> - notificationManager.activeNotifications.firstOrNull { it.id == notificationId }?.let { + + val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> + notificationManager.activeNotifications.firstOrNull { it.id == id }?.let { notificationBuilder(it.notification) XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( it.tag, it.id, it.notification, it.user @@ -131,22 +150,35 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } - val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") - .toString() - val conversationId = intent.getStringExtra("conversation_id")!! - val notificationId = intent.getIntExtra("notification_id", -1) + when (intent.action) { + ACTION_REPLY -> { + val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") + .toString() - context.database.getMyUserId()?.let { context.database.getFriendInfo(it) }?.let { myUser -> - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input") + context.database.getMyUserId()?.let { context.database.getFriendInfo(it) }?.let { myUser -> + cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input") - updateNotification(notificationId) { notification -> - setNotificationText(notification, computeNotificationText(conversationId)) - } + updateNotification(notificationId) { notification -> + notification.flags = notification.flags or Notification.FLAG_ONLY_ALERT_ONCE + setNotificationText(notification, conversationId) + } - context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = { - context.longToast("Failed to send message: $it") - }) + context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = { + context.longToast("Failed to send message: $it") + }) + } + } + ACTION_DOWNLOAD -> { + runCatching { + context.feature(MediaDownloader::class).downloadMessageId(messageId, isPreview = false) + }.onFailure { + context.longToast(it) + } + } + else -> return@hook } + + param.setResult(null) } } @@ -160,90 +192,80 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN )) } - notificationDataQueue.entries.onEach { (messageId, notificationData) -> - val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return - val senderUsername by lazy { - context.database.getFriendInfo(snapMessage.senderId.toString())?.let { - it.displayName ?: it.username + synchronized(notificationDataQueue) { + notificationDataQueue.entries.onEach { (messageId, notificationData) -> + val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return + val senderUsername by lazy { + context.database.getFriendInfo(snapMessage.senderId.toString())?.let { + it.displayName ?: it.username + } } - } - val contentType = snapMessage.messageContent.contentType - val contentData = snapMessage.messageContent.content + val contentType = snapMessage.messageContent.contentType + val contentData = snapMessage.messageContent.content - val formatUsername: (String) -> String = { "$senderUsername: $it" } - val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } } - val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, computeNotificationText(conversationId))} + val formatUsername: (String) -> String = { "$senderUsername: $it" } + val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } } + val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, conversationId)} - when (contentType) { - ContentType.NOTE -> { - notificationCache.add(formatUsername("sent audio note")) - appendNotifications() - } - ContentType.CHAT -> { - ProtoReader(contentData).getString(2, 1)?.trim()?.let { - notificationCache.add(formatUsername(it)) - } - appendNotifications() - } - ContentType.SNAP, ContentType.EXTERNAL_MEDIA-> { - //serialize the message content into a json object - val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject - val mediaReferences = serializedMessageContent["mRemoteMediaReferences"] - .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray } - .flatten() - - mediaReferences.forEach { media -> - val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() - val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) - runCatching { - val messageReader = ProtoReader(contentData) - val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { - EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false) - } + setupNotificationActionButtons(contentType, conversationId, snapMessage.messageDescriptor.messageId, notificationData) - var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[MediaType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! - - downloadedMediaList[MediaType.OVERLAY]?.let { - bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) + when (contentType) { + ContentType.NOTE -> { + notificationCache.add(formatUsername("sent audio note")) + appendNotifications() + } + ContentType.CHAT -> { + ProtoReader(contentData).getString(2, 1)?.trim()?.let { + notificationCache.add(formatUsername(it)) + } + appendNotifications() + } + ContentType.SNAP, ContentType.EXTERNAL_MEDIA-> { + //serialize the message content into a json object + val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject + val mediaReferences = serializedMessageContent["mRemoteMediaReferences"] + .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray } + .flatten() + + mediaReferences.forEach { media -> + val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() + val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) + runCatching { + val messageReader = ProtoReader(contentData) + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { + EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false) + } + + var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! + + downloadedMediaList[SplitMediaAssetType.OVERLAY]?.let { + bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + val notificationBuilder = XposedHelpers.newInstance( + Notification.Builder::class.java, + context.androidContext, + notificationData.notification + ) as Notification.Builder + notificationBuilder.setLargeIcon(bitmapPreview) + notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) + + sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true) + return@onEach + }.onFailure { + Logger.xposedLog("Failed to send preview notification", it) } - - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - notificationBuilder.setLargeIcon(bitmapPreview) - notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) - - sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true) - return@onEach - }.onFailure { - Logger.xposedLog("Failed to send preview notification", it) } } + else -> { + notificationCache.add(formatUsername("sent $contentType")) + } } - else -> { - notificationCache.add(formatUsername("sent $contentType")) - } - } - - if (contentType == ContentType.CHAT && context.config.options(ConfigProperty.BETTER_NOTIFICATIONS)["reply_button"] == true) { - setupNotificationActionButtons(conversationId, notificationData) - } - - sendNotificationData(notificationData, false) - }.clear() - } - - private fun shouldIgnoreNotification(type: String): Boolean { - val states = context.config.options(ConfigProperty.NOTIFICATION_BLACKLIST) - states["snap"]?.let { if (type.endsWith("SNAP") && it) return true } - states["chat"]?.let { if (type.endsWith("CHAT") && it) return true } - states["typing"]?.let { if (type.endsWith("TYPING") && it) return true } - - return false + sendNotificationData(notificationData, false) + }.clear() + } } override fun init() { @@ -260,16 +282,16 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val notificationType = extras.getString("notification_type") ?: return@hook val conversationId = extras.getString("conversation_id") ?: return@hook - if (shouldIgnoreNotification(notificationType)) { - param.setResult(null) - return@hook - } - if (context.config.options(ConfigProperty.BETTER_NOTIFICATIONS) - .filter { it.value }.none { notificationType.endsWith(it.key.uppercase())}) return@hook + .filter { it.value }.map { it.key.uppercase() }.none { + notificationType.contains(it) + }) return@hook val conversationManager: Any = context.feature(Messaging::class).conversationManager - notificationDataQueue[messageId.toLong()] = notificationData + + synchronized(notificationDataQueue) { + notificationDataQueue[messageId.toLong()] = notificationData + } val callback = CallbackBuilder(fetchConversationWithMessagesCallback) .override("onFetchConversationWithMessagesComplete") { callbackParam -> @@ -284,12 +306,32 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN param.setResult(null) } - Hooker.hook(cancelAsUserMethod, HookStage.BEFORE) { param -> + XposedHelpers.findMethodExact( + NotificationManager::class.java, + "cancelAsUser", String::class.java, + Int::class.javaPrimitiveType, + UserHandle::class.java + ).hook(HookStage.BEFORE) { param -> val notificationId = param.arg<Int>(1) notificationIdMap[notificationId]?.let { cachedMessages[it]?.clear() } } + + findClass("com.google.firebase.messaging.FirebaseMessagingService").run { + methods.first { it.declaringClass == this && it.returnType == Void::class.javaPrimitiveType && it.parameterCount == 1 && it.parameterTypes[0] == Intent::class.java } + .hook(HookStage.BEFORE) { param -> + val intent = param.argNullable<Intent>(0) ?: return@hook + val messageType = intent.getStringExtra("type") ?: return@hook + val states = context.config.options(ConfigProperty.NOTIFICATION_BLACKLIST) + + Logger.xposedLog("received message type: $messageType") + + if (states[messageType.replaceFirst("mischief_", "")] == true) { + param.setResult(null) + } + } + } } data class NotificationData( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features.impl.ui -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt @@ -1,7 +1,9 @@ package me.rhunk.snapenhance.features.impl.ui import android.annotation.SuppressLint +import android.content.Context import android.content.res.Resources +import android.text.SpannableString import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -32,7 +34,7 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE param.setResult(null) } - @SuppressLint("DiscouragedApi") + @SuppressLint("DiscouragedApi", "InternalInsetResource") override fun onActivityCreate() { val blockAds = context.config.bool(ConfigProperty.BLOCK_ADS) val hiddenElements = context.config.options(ConfigProperty.HIDE_UI_ELEMENTS) @@ -92,6 +94,20 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE hideStorySection(param) } + //mappings? + if (hideStorySection["hide_friend_suggestions"] == true && view.javaClass.superclass?.name?.endsWith("StackDrawLayout") == true) { + val layoutParams = view.layoutParams as? FrameLayout.LayoutParams ?: return@hook + if (layoutParams.width == -1 && + layoutParams.height == -2 && + view.javaClass.let { clazz -> + clazz.methods.any { it.returnType == SpannableString::class.java} && + clazz.constructors.any { it.parameterCount == 1 && it.parameterTypes[0] == Context::class.java } + } + ) { + hideStorySection(param) + } + } + if (hideStorySection["hide_following"] == true && (viewId == getIdentifier("df_small_story", "id")) ) { hideStorySection(param) @@ -101,9 +117,20 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE hideStorySection(param) } - if (isImmersiveCamera && view.id == getIdentifier("full_screen_surface_view", "id")) { - Hooker.hookObjectMethod(View::class.java, view, "setLayoutParams", HookStage.BEFORE) { - it.setArg(0, FrameLayout.LayoutParams(displayMetrics.widthPixels, displayMetrics.heightPixels)) + if (isImmersiveCamera) { + if (view.id == getIdentifier("edits_container", "id")) { + val deviceAspectRatio = displayMetrics.widthPixels.toFloat() / displayMetrics.heightPixels.toFloat() + Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { + val width = it.arg(2) as Int + val realHeight = (width / deviceAspectRatio).toInt() + it.setArg(3, realHeight) + } + } + if (view.id == getIdentifier("full_screen_surface_view", "id")) { + Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { + it.setArg(1, 1) + it.setArg(3, displayMetrics.heightPixels) + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt @@ -5,82 +5,78 @@ import de.robv.android.xposed.XposedBridge import java.lang.reflect.Member object Hooker { - private fun newMethodHook( + inline fun newMethodHook( stage: HookStage, - consumer: (HookAdapter) -> Unit, - filter: ((HookAdapter) -> Boolean) = { true } + crossinline consumer: (HookAdapter) -> Unit, + crossinline filter: ((HookAdapter) -> Boolean) = { true } ): XC_MethodHook { - val callEvent = { param: XC_MethodHook.MethodHookParam<*> -> - HookAdapter(param).takeIf(filter)?.also(consumer) - } - return if (stage == HookStage.BEFORE) object : XC_MethodHook() { override fun beforeHookedMethod(param: MethodHookParam<*>) { - callEvent(param) + HookAdapter(param).takeIf(filter)?.also(consumer) } } else object : XC_MethodHook() { override fun afterHookedMethod(param: MethodHookParam<*>) { - callEvent(param) + HookAdapter(param).takeIf(filter)?.also(consumer) } } } - fun hook( + inline fun hook( clazz: Class<*>, methodName: String, stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer) - fun hook( + inline fun hook( clazz: Class<*>, methodName: String, stage: HookStage, - filter: (HookAdapter) -> Boolean, - consumer: (HookAdapter) -> Unit + crossinline filter: (HookAdapter) -> Boolean, + crossinline consumer: (HookAdapter) -> Unit ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) - fun hook( + inline fun hook( member: Member, stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ): XC_MethodHook.Unhook { return hook(member, stage, { true }, consumer) } - fun hook( + inline fun hook( member: Member, stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit ): XC_MethodHook.Unhook { return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) } - fun hookConstructor( + inline fun hookConstructor( clazz: Class<*>, stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ) { XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) } - fun hookConstructor( + inline fun hookConstructor( clazz: Class<*>, stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit ) { XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) } - fun hookObjectMethod( + inline fun hookObjectMethod( clazz: Class<*>, instance: Any, methodName: String, stage: HookStage, - hookConsumer: (HookAdapter) -> Unit + crossinline hookConsumer: (HookAdapter) -> Unit ) { val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() hook(clazz, methodName, stage) { param-> @@ -92,11 +88,11 @@ object Hooker { }.also { unhooks.addAll(it) } } - fun ephemeralHook( + inline fun ephemeralHook( clazz: Class<*>, methodName: String, stage: HookStage, - hookConsumer: (HookAdapter) -> Unit + crossinline hookConsumer: (HookAdapter) -> Unit ) { val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() hook(clazz, methodName, stage) { param-> @@ -105,12 +101,12 @@ object Hooker { }.also { unhooks.addAll(it) } } - fun ephemeralHookObjectMethod( + inline fun ephemeralHookObjectMethod( clazz: Class<*>, instance: Any, methodName: String, stage: HookStage, - hookConsumer: (HookAdapter) -> Unit + crossinline hookConsumer: (HookAdapter) -> Unit ) { val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() hook(clazz, methodName, stage) { param-> @@ -121,37 +117,37 @@ object Hooker { } } -fun Class<*>.hookConstructor( +inline fun Class<*>.hookConstructor( stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ) = Hooker.hookConstructor(this, stage, consumer) -fun Class<*>.hookConstructor( +inline fun Class<*>.hookConstructor( stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit ) = Hooker.hookConstructor(this, stage, filter, consumer) -fun Class<*>.hook( +inline fun Class<*>.hook( methodName: String, stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer) -fun Class<*>.hook( +inline fun Class<*>.hook( methodName: String, stage: HookStage, - filter: (HookAdapter) -> Boolean, - consumer: (HookAdapter) -> Unit + crossinline filter: (HookAdapter) -> Boolean, + crossinline consumer: (HookAdapter) -> Unit ): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer) -fun Member.hook( +inline fun Member.hook( stage: HookStage, - consumer: (HookAdapter) -> Unit + crossinline consumer: (HookAdapter) -> Unit ): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer) -fun Member.hook( +inline fun Member.hook( stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit ): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer) \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.manager.impl import com.google.gson.JsonObject import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.config.ConfigAccessor import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.manager.Manager 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 @@ -11,35 +11,37 @@ import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.experiments.AmoledDarkMode import me.rhunk.snapenhance.features.impl.experiments.AppPasscode +import me.rhunk.snapenhance.features.impl.experiments.DeviceSpooferHook import me.rhunk.snapenhance.features.impl.experiments.InfiniteStoryBoost import me.rhunk.snapenhance.features.impl.experiments.MeoPasscodeBypass import me.rhunk.snapenhance.features.impl.experiments.UnlimitedMultiSnap +import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics +import me.rhunk.snapenhance.features.impl.privacy.PreventMessageSending +import me.rhunk.snapenhance.features.impl.spying.AnonymousStoryViewing +import me.rhunk.snapenhance.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts +import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave import me.rhunk.snapenhance.features.impl.tweaks.AutoSave +import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride +import me.rhunk.snapenhance.features.impl.tweaks.GooglePlayServicesDialogs import me.rhunk.snapenhance.features.impl.tweaks.LocationSpoofer import me.rhunk.snapenhance.features.impl.tweaks.MediaQualityLevelOverride import me.rhunk.snapenhance.features.impl.tweaks.Notifications import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime -import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics -import me.rhunk.snapenhance.features.impl.privacy.PreventMessageSending -import me.rhunk.snapenhance.features.impl.spying.AnonymousStoryViewing -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.features.impl.ui.PinConversations import me.rhunk.snapenhance.features.impl.ui.StartupPageOverride import me.rhunk.snapenhance.features.impl.ui.UITweaks -import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector import me.rhunk.snapenhance.manager.Manager +import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector import java.util.concurrent.Executors import kotlin.reflect.KClass class FeatureManager(private val context: ModContext) : Manager { - private val asyncLoadExecutorService = Executors.newCachedThreadPool() + private val asyncLoadExecutorService = Executors.newFixedThreadPool(5) private val features = mutableListOf<Feature>() private fun register(featureClass: KClass<out Feature>) { @@ -88,7 +90,9 @@ class FeatureManager(private val context: ModContext) : Manager { register(AmoledDarkMode::class) register(PinConversations::class) register(UnlimitedMultiSnap::class) + register(DeviceSpooferHook::class) register(StartupPageOverride::class) + register(GooglePlayServicesDialogs::class) initializeFeatures() } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt @@ -2,45 +2,44 @@ package me.rhunk.snapenhance.manager.impl import android.app.AlertDialog import com.google.gson.JsonElement -import com.google.gson.JsonObject import com.google.gson.JsonParser -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.ModContext -import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.manager.Manager -import me.rhunk.snapenhance.mapping.Mapper -import me.rhunk.snapenhance.mapping.impl.BCryptClassMapper -import me.rhunk.snapenhance.mapping.impl.CallbackMapper -import me.rhunk.snapenhance.mapping.impl.DefaultMediaItemMapper -import me.rhunk.snapenhance.mapping.impl.EnumMapper -import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper -import me.rhunk.snapenhance.mapping.impl.PlatformAnalyticsCreatorMapper -import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper -import me.rhunk.snapenhance.mapping.impl.ScCameraSettingsMapper -import me.rhunk.snapenhance.mapping.impl.StoryBoostStateMapper -import me.rhunk.snapenhance.util.getObjectField +import me.rhunk.snapenhance.ui.ViewAppearanceHelper +import me.rhunk.snapmapper.Mapper +import me.rhunk.snapmapper.impl.BCryptClassMapper +import me.rhunk.snapmapper.impl.CallbackMapper +import me.rhunk.snapmapper.impl.DefaultMediaItemMapper +import me.rhunk.snapmapper.impl.EnumMapper +import me.rhunk.snapmapper.impl.FriendsFeedEventDispatcherMapper +import me.rhunk.snapmapper.impl.MediaQualityLevelProviderMapper +import me.rhunk.snapmapper.impl.OperaPageViewControllerMapper +import me.rhunk.snapmapper.impl.PlatformAnalyticsCreatorMapper +import me.rhunk.snapmapper.impl.PlusSubscriptionMapper +import me.rhunk.snapmapper.impl.ScCameraSettingsMapper +import me.rhunk.snapmapper.impl.StoryBoostStateMapper import java.nio.charset.StandardCharsets import java.util.concurrent.ConcurrentHashMap +import kotlin.system.measureTimeMillis @Suppress("UNCHECKED_CAST") class MappingManager(private val context: ModContext) : Manager { - private val mappers = mutableListOf<Mapper>().apply { - add(CallbackMapper()) - add(EnumMapper()) - add(OperaPageViewControllerMapper()) - add(PlusSubscriptionMapper()) - add(DefaultMediaItemMapper()) - add(BCryptClassMapper()) - add(PlatformAnalyticsCreatorMapper()) - add(ScCameraSettingsMapper()) - add(StoryBoostStateMapper()) - } + private val mappers = arrayOf( + BCryptClassMapper::class, + CallbackMapper::class, + DefaultMediaItemMapper::class, + MediaQualityLevelProviderMapper::class, + EnumMapper::class, + OperaPageViewControllerMapper::class, + PlatformAnalyticsCreatorMapper::class, + PlusSubscriptionMapper::class, + ScCameraSettingsMapper::class, + StoryBoostStateMapper::class, + FriendsFeedEventDispatcherMapper::class + ) private val mappings = ConcurrentHashMap<String, Any>() val areMappingsLoaded: Boolean @@ -69,7 +68,7 @@ class MappingManager(private val context: ModContext) : Manager { return } context.runOnUiThread { - val statusDialogBuilder = AlertDialog.Builder(context.mainActivity, AlertDialog.THEME_DEVICE_DEFAULT_DARK) + val statusDialogBuilder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setMessage("Generating mappings, please wait...") .setCancelable(false) .setView(android.widget.ProgressBar(context.mainActivity).apply { @@ -127,70 +126,27 @@ class MappingManager(private val context: ModContext) : Manager { } } - private fun executeMappers(classes: List<Class<*>>) = runBlocking { - val jobs = mutableListOf<Job>() - mappers.forEach { mapper -> - mapper.context = context - launch { - runCatching { - mapper.useClasses(context.androidContext.classLoader, classes, mappings) - }.onFailure { - Logger.xposedLog("Failed to execute mapper ${mapper.javaClass.simpleName}", it) - } - }.also { jobs.add(it) } - } - jobs.joinAll() - } - - @Suppress("UNCHECKED_CAST", "DEPRECATION") + @Suppress("DEPRECATION") private fun refresh() { - val classes: MutableList<Class<*>> = ArrayList() - - val classLoader = context.androidContext.classLoader - val dexPathList = classLoader.getObjectField("pathList")!! - val dexElements = dexPathList.getObjectField("dexElements") as Array<Any> - - dexElements.forEach { dexElement: Any -> - (dexElement.getObjectField("dexFile") as dalvik.system.DexFile?)?.apply { - entries().toList().forEach fileList@{ className -> - //ignore classes without a dot in them - if (className.contains(".") && !className.startsWith("com.snap")) return@fileList - runCatching { - classLoader.loadClass(className)?.let { - //force load fields to avoid ClassNotFoundExceptions when executing mappers - it.declaredFields - classes.add(it) - } - }.onFailure { - Logger.debug("Failed to load class $className") - } - } - } + val mapper = Mapper(*mappers) + + runCatching { + mapper.loadApk(context.androidContext.packageManager.getApplicationInfo( + Constants.SNAPCHAT_PACKAGE_NAME, + 0 + ).sourceDir) + }.onFailure { + throw Exception("Failed to load APK", it) } - executeMappers(classes) - write() - } - - private fun write() { - val mappingsObject = JsonObject() - mappingsObject.addProperty("snap_build_number", snapBuildNumber) - mappings.forEach { (key, value) -> - if (value is List<*>) { - mappingsObject.add(key, context.gson.toJsonTree(value)) - return@forEach + measureTimeMillis { + val result = mapper.start().apply { + addProperty("snap_build_number", snapBuildNumber) } - if (value is Map<*, *>) { - mappingsObject.add(key, context.gson.toJsonTree(value)) - return@forEach - } - mappingsObject.addProperty(key, value.toString()) + context.bridgeClient.writeFile(BridgeFileType.MAPPINGS, result.toString().toByteArray()) + }.also { + Logger.xposedLog("Generated mappings in $it ms") } - - context.bridgeClient.writeFile( - BridgeFileType.MAPPINGS, - mappingsObject.toString().toByteArray() - ) } fun getMappedObject(key: String): Any { @@ -200,6 +156,10 @@ class MappingManager(private val context: ModContext) : Manager { throw Exception("No mapping found for $key") } + fun getMappedObjectNullable(key: String): Any? { + return mappings[key] + } + fun getMappedClass(className: String): Class<*> { return context.androidContext.classLoader.loadClass(getMappedObject(className) as String) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt @@ -1,13 +0,0 @@ -package me.rhunk.snapenhance.mapping - -import me.rhunk.snapenhance.ModContext - -abstract class Mapper { - lateinit var context: ModContext - - abstract fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/BCryptClassMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/BCryptClassMapper.kt @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - -class BCryptClassMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (!Modifier.isFinal(clazz.modifiers)) continue - clazz.declaredFields.firstOrNull { it.type == IntArray::class.java && Modifier.isStatic(it.modifiers)}?.let { field -> - val fieldData = field.get(null) - if (fieldData !is IntArray) return@let - if (fieldData.size != 18 || fieldData[0] != 608135816) return@let - mappings["BCryptClass"] = clazz.name - mappings["BCryptClassHashMethod"] = clazz.methods.first { - it.parameterTypes.size == 2 && it.returnType == String::class.java && it.parameterTypes[0] == String::class.java && it.parameterTypes[1] == String::class.java - }.name - return - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt @@ -1,29 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Method -import java.lang.reflect.Modifier - -class CallbackMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - val callbackMappings = HashMap<String, String>() - classes.forEach { clazz -> - val superClass = clazz.superclass ?: return@forEach - if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach - if (!Modifier.isAbstract(superClass.modifiers)) return@forEach - - if (superClass.declaredMethods.any { method: Method -> - method.name == "onError" - }) { - callbackMappings[superClass.simpleName] = clazz.name - } - } - debug("found " + callbackMappings.size + " callbacks") - mappings["callbacks"] = callbackMappings - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/DefaultMediaItemMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/DefaultMediaItemMapper.kt @@ -1,24 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import android.net.Uri -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - -class DefaultMediaItemMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.superclass == null || !Modifier.isAbstract(clazz.superclass.modifiers)) continue - if (clazz.superclass.interfaces.isEmpty() || clazz.superclass.interfaces[0] != Comparable::class.java) continue - if (clazz.methods.none { it.returnType == Uri::class.java }) continue - - val constructorParameters = clazz.constructors[0]?.parameterTypes ?: continue - if (constructorParameters.size < 6 || constructorParameters[5] != Long::class.javaPrimitiveType) continue - - mappings["DefaultMediaItem"] = clazz.name - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt @@ -1,66 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Method -import java.lang.reflect.Modifier -import java.util.Objects - - -class EnumMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - val enumMappings = HashMap<String, String>() - var enumQualityLevel: Class<*>? = null - - //settings classes have an interface that extends Serializable and contains the getName method - //this enum classes are used to store the settings values - //Setting enum class -> implements an interface -> getName method - classes.forEach { clazz -> - if (!clazz.isEnum) return@forEach - - //quality level enum - if (enumQualityLevel == null) { - if (clazz.enumConstants.any { it.toString().startsWith("LEVEL_NONE") }) { - enumMappings["QualityLevel"] = clazz.name - enumQualityLevel = clazz - } - } - - if (clazz.interfaces.isEmpty()) return@forEach - val serializableInterfaceClass = clazz.interfaces[0] - if (serializableInterfaceClass.methods - .filter { method: Method -> method.declaringClass == serializableInterfaceClass } - .none { method: Method -> method.name == "getName" } - ) return@forEach - - runCatching { - val getEnumNameMethod = - serializableInterfaceClass.methods.first { it!!.returnType.isEnum } - clazz.enumConstants?.onEach { enumConstant -> - val enumName = - Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString() - enumMappings[enumName] = clazz.name - } - } - } - - debug("found " + enumMappings.size + " enums") - mappings["enums"] = enumMappings - - //find the media quality level provider - for (clazz in classes) { - if (!Modifier.isAbstract(clazz.modifiers)) continue - if (clazz.fields.none { Modifier.isTransient(it.modifiers) }) continue - clazz.methods.firstOrNull { it.returnType == enumQualityLevel }?.let { - mappings["MediaQualityLevelProvider"] = clazz.name - mappings["MediaQualityLevelProviderMethod"] = it.name - debug("found MediaQualityLevelProvider: ${clazz.name}.${it.name}") - return - } - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt @@ -1,78 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import me.rhunk.snapenhance.util.ReflectionHelper -import java.lang.reflect.Field -import java.lang.reflect.Method -import java.lang.reflect.Modifier -import java.util.Arrays - - -class OperaPageViewControllerMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - var operaPageViewControllerClass: Class<*>? = null - for (aClass in classes) { - if (!Modifier.isAbstract(aClass.modifiers)) continue - if (aClass.interfaces.isEmpty()) continue - val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field -> - val modifiers = field.modifiers - Modifier.isStatic(modifiers) && Modifier.isFinal( - modifiers - ) - }.filter { field: Field -> - try { - return@filter "ad_product_type" == String.format("%s", field[null]) - } catch (e: IllegalAccessException) { - e.printStackTrace() - } - false - }.count() - if (foundFields == 0L) continue - operaPageViewControllerClass = aClass - break - } - if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found") - - val members = HashMap<String, String>() - members["Class"] = operaPageViewControllerClass.name - - operaPageViewControllerClass.fields.forEach { field -> - val fieldType = field.type - if (fieldType.isEnum) { - fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" } - .let { members["viewStateField"] = field.name } - } - if (fieldType == ArrayList::class.java) { - members["layerListField"] = field.name - } - } - val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field -> - field.name == members["viewStateField"] - }.type - - //find the method that call the onDisplayStateChange method - members["onDisplayStateChange"] = - operaPageViewControllerClass.methods.first { method: Method -> - if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false - val firstParameterClass = method.parameterTypes[0] - //check if the class contains a field with the enumViewStateClass type - ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null - }.name - - //find the method that call the onDisplayStateChange method from gestures - members["onDisplayStateChange2"] = - operaPageViewControllerClass.methods.first { method: Method -> - if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false - val firstParameterClass = method.parameterTypes[0] - val secondParameterClass = method.parameterTypes[1] - firstParameterClass.isEnum && secondParameterClass.isEnum - }.name - - mappings["OperaPageViewController"] = members - return - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlatformAnalyticsCreatorMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlatformAnalyticsCreatorMapper.kt @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class PlatformAnalyticsCreatorMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.isEnum || clazz.isInterface) continue - val constructors = clazz.constructors - if (constructors.isEmpty()) continue - val firstConstructor = constructors[0] - // 47 is the number of parameters of the constructor - // can change in future versions - if (firstConstructor.parameterCount != 47) continue - if (!firstConstructor.parameterTypes[0].isEnum) continue - if (firstConstructor.parameterTypes[0].enumConstants.none { it.toString() == "IN_APP_NOTIFICATION" }) continue - - mappings["PlatformAnalyticsCreator"] = clazz.name - return - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt @@ -1,27 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper -import java.lang.reflect.Modifier - - -class PlusSubscriptionMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - clazz.declaredFields.firstOrNull { - it.type == clazz && - Modifier.isFinal(it.modifiers) && - Modifier.isStatic(it.modifiers) && - runCatching { - it?.get(null).toString().startsWith("PlusSubscriptionState") - }.getOrDefault(false) - } ?: continue - - mappings["SubscriptionInfoClass"] = clazz.constructors[0]!!.parameterTypes[0]!!.name - return - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/ScCameraSettingsMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/ScCameraSettingsMapper.kt @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class ScCameraSettingsMapper : Mapper() { - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - if (clazz.constructors.isEmpty()) continue - val parameters = clazz.constructors.first().parameterTypes - if (parameters.size < 27) continue - val firstParameter = parameters[0] - if (!firstParameter.isEnum || firstParameter.enumConstants.find { it.toString() == "CONTINUOUS_PICTURE" } == null) continue - mappings["ScCameraSettings"] = clazz.name - return - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/StoryBoostStateMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/StoryBoostStateMapper.kt @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.mapping.impl - -import me.rhunk.snapenhance.mapping.Mapper - -class StoryBoostStateMapper : Mapper(){ - override fun useClasses( - classLoader: ClassLoader, - classes: List<Class<*>>, - mappings: MutableMap<String, Any> - ) { - for (clazz in classes) { - val firstConstructor = clazz.constructors.firstOrNull() ?: continue - if (firstConstructor.parameterCount != 3) continue - if (firstConstructor.parameterTypes[1] != Long::class.javaPrimitiveType || firstConstructor.parameterTypes[2] != Long::class.javaPrimitiveType) continue - val storyBoostEnumClass = firstConstructor.parameterTypes[0] - if (!storyBoostEnumClass.isEnum || storyBoostEnumClass.enumConstants.none { it.toString() == "NeedSubscriptionCannotSubscribe" }) continue - mappings["StoryBoostStateClass"] = clazz.name - return - } - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/ItemHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/ItemHelper.kt @@ -0,0 +1,85 @@ +package me.rhunk.snapenhance.ui + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.SharedContext +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.util.ActivityResultCallback +import kotlin.math.abs +import kotlin.random.Random + +class ItemHelper( + private val context : Context +) { + val positiveButtonText by lazy { + SharedContext.translation["button.ok"] + } + + val cancelButtonText by lazy { + SharedContext.translation["button.cancel"] + } + + fun longToast(message: String, context: Context) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + + fun createTranslatedTextView(property: ConfigProperty, shouldTranslatePropertyValue: Boolean = true): TextView { + return object: TextView(context) { + override fun setText(text: CharSequence?, type: BufferType?) { + val newText = text?.takeIf { it.isNotEmpty() }?.let { + if (!shouldTranslatePropertyValue || property.disableValueLocalization) it + else SharedContext.translation[property.getOptionTranslationKey(it.toString())] + }?.let { + if (it.length > 20) { + "...${it.substring(it.length - 20)}" + } else { + it + } + } ?: "" + super.setTextColor(context.getColor(R.color.tertiaryText)) + super.setText(newText, type) + } + } + } + + fun askForValue(property: ConfigProperty, requestedInputType: Int, callback: (String) -> Unit) { + val editText = EditText(context).apply { + inputType = requestedInputType + setText(property.valueContainer.value().toString()) + } + AlertDialog.Builder(context) + .setTitle(SharedContext.translation["property.${property.translationKey}.name"]) + .setView(editText) + .setPositiveButton(positiveButtonText) { _, _ -> + callback(editText.text.toString()) + } + .setNegativeButton(cancelButtonText) { dialog, _ -> + dialog.cancel() + } + .show() + } + + fun askForFolder(activity: Activity, property: ConfigProperty, callback: (String) -> Unit): Pair<Int, ActivityResultCallback> { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + val requestCode = abs(Random.nextInt()) + activity.startActivityForResult(intent, requestCode) + + return requestCode to let@{_, resultCode, data -> + if (resultCode != Activity.RESULT_OK) return@let + val uri = data?.data ?: return@let + val value = uri.toString() + activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + property.valueContainer.writeFrom(value) + callback(value) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt @@ -0,0 +1,97 @@ +package me.rhunk.snapenhance.ui + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.view.Gravity +import android.view.View +import android.widget.Switch +import android.widget.TextView +import me.rhunk.snapenhance.Constants + +object ViewAppearanceHelper { + @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", + "ClickableViewAccessibility" + ) + private var sigColorTextPrimary: Int = 0 + private var sigColorBackgroundSurface: Int = 0 + + private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { + if (!hasRadius) return ColorDrawable(color) + //FIXME: hardcoded radius + return ShapeDrawable().apply { + paint.color = color + shape = android.graphics.drawable.shapes.RoundRectShape( + floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), + null, + null + ) + } + } + + @SuppressLint("DiscouragedApi") + fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false) { + val resources = component.context.resources + if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { + with(component.context.theme) { + sigColorTextPrimary = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + + sigColorBackgroundSurface = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + } + } + + val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android") + val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 + + with(component) { + if (this is TextView) { + setTextColor(sigColorTextPrimary) + setShadowLayer(0F, 0F, 0F, 0) + gravity = Gravity.CENTER_VERTICAL + componentWidth?.let { width = it} + height = (150 * scalingFactor).toInt() + isAllCaps = false + textSize = 16f + typeface = resources.getFont(snapchatFontResId) + outlineProvider = null + setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) + } + background = StateListDrawable().apply { + addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, hasRadius)) + addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, hasRadius)) + } + } + + if (component is Switch) { + with(resources) { + component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() + } + component.trackTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#1d1d1d"), + Color.parseColor("#26bd49") + ) + ) + component.thumbTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#F5F5F5"), + Color.parseColor("#26bd49") + ) + ) + } + } + + fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert) +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/config/ConfigActivity.kt @@ -2,13 +2,13 @@ package me.rhunk.snapenhance.ui.config import android.app.Activity import android.app.AlertDialog +import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle import android.text.Html import android.text.InputType import android.view.View import android.view.ViewGroup -import android.widget.EditText import android.widget.ImageButton import android.widget.Switch import android.widget.TextView @@ -16,7 +16,6 @@ import android.widget.Toast import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.R import me.rhunk.snapenhance.SharedContext -import me.rhunk.snapenhance.bridge.ConfigWrapper import me.rhunk.snapenhance.config.ConfigCategory import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.config.impl.ConfigIntegerValue @@ -24,9 +23,14 @@ import me.rhunk.snapenhance.config.impl.ConfigStateListValue import me.rhunk.snapenhance.config.impl.ConfigStateSelection import me.rhunk.snapenhance.config.impl.ConfigStateValue import me.rhunk.snapenhance.config.impl.ConfigStringValue +import me.rhunk.snapenhance.ui.ItemHelper +import me.rhunk.snapenhance.util.ActivityResultCallback +import kotlin.system.exitProcess + class ConfigActivity : Activity() { - private val config = ConfigWrapper() + private val itemHelper = ItemHelper(this) + private val activityResultCallbacks = mutableMapOf<Int, ActivityResultCallback>() @Deprecated("Deprecated in Java") @Suppress("DEPRECATION") @@ -37,65 +41,20 @@ class ConfigActivity : Activity() { override fun onDestroy() { super.onDestroy() - config.writeConfig() + SharedContext.config.writeConfig() } override fun onPause() { super.onPause() - config.writeConfig() - } - - private val positiveButtonText by lazy { - SharedContext.translation["button.ok"] - } - - private val cancelButtonText by lazy { - SharedContext.translation["button.cancel"] - } - - private fun longToast(message: String) { - Toast.makeText(this, message, Toast.LENGTH_LONG).show() + SharedContext.config.writeConfig() } - private fun createTranslatedTextView(property: ConfigProperty, shouldTranslatePropertyValue: Boolean = true): TextView { - return object: TextView(this) { - override fun setText(text: CharSequence?, type: BufferType?) { - val newText = text?.takeIf { it.isNotEmpty() }?.let { - if (!shouldTranslatePropertyValue || property.disableValueLocalization) it - else SharedContext.translation["option.property." + property.translationKey + "." + it] - }?.let { - if (it.length > 20) { - it.substring(0, 20) + "..." - } else { - it - } - } ?: "" - super.setTextColor(getColor(R.color.tertiaryText)) - super.setText(newText, type) - } - } - } - - private fun askForValue(property: ConfigProperty, requestedInputType: Int, callback: (String) -> Unit) { - val editText = EditText(this).apply { - inputType = requestedInputType - setText(property.valueContainer.value().toString()) - } - AlertDialog.Builder(this) - .setTitle(SharedContext.translation["property.${property.translationKey}.name"]) - .setView(editText) - .setPositiveButton(positiveButtonText) { _, _ -> - callback(editText.text.toString()) - } - .setNegativeButton(cancelButtonText) { dialog, _ -> - dialog.cancel() - } - .show() + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + activityResultCallbacks[requestCode]?.invoke(requestCode, resultCode, data) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - config.loadFromContext(this) SharedContext.ensureInitialized(this) setContentView(R.layout.config_activity) @@ -126,9 +85,37 @@ class ConfigActivity : Activity() { }) } + //check if save folder is set + //TODO: first run activity + run { + val saveFolder = SharedContext.config.string(ConfigProperty.SAVE_FOLDER) + val itemHelper = ItemHelper(this) + + if (saveFolder.isEmpty() || !saveFolder.startsWith("content://")) { + AlertDialog.Builder(this) + .setTitle("Save folder") + .setMessage("Please select a folder where you want to save downloaded files.") + .setPositiveButton("Select") { _, _ -> + val (requestCode, callback) = itemHelper.askForFolder( + this, + ConfigProperty.SAVE_FOLDER + ) {} + activityResultCallbacks[requestCode] = { a1, a2, a3 -> + callback(a1, a2, a3) + Toast.makeText(this, "Save Folder set!", Toast.LENGTH_SHORT).show() + finish() + } + } + .setNegativeButton("Cancel") { _, _ -> + exitProcess(0) + } + .show() + } + } + var currentCategory: ConfigCategory? = null - config.entries().forEach { (property, value) -> + SharedContext.config.entries().filter { !it.key.category.hidden }.forEach { (property, value) -> val configItem = layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false) fun addSeparator() { @@ -140,6 +127,7 @@ class ConfigActivity : Activity() { } if (property.category != currentCategory) { + if(!property.shouldAppearInSettings) return@forEach currentCategory = property.category with(layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false)) { findViewById<TextView>(R.id.name).apply { @@ -187,22 +175,32 @@ class ConfigActivity : Activity() { addValueView(switch) } is ConfigStringValue, is ConfigIntegerValue -> { - val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = false).also { + val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false).also { it.text = value.value().toString() } configItem.setOnClickListener { + if (value is ConfigStringValue && value.isFolderPath) { + val (requestCode, callback) = itemHelper.askForFolder(this, property) { + value.writeFrom(it) + textView.text = value.value() + } + + activityResultCallbacks[requestCode] = callback + return@setOnClickListener + } + if (value is ConfigIntegerValue) { - askForValue(property, InputType.TYPE_CLASS_NUMBER) { + itemHelper.askForValue(property, InputType.TYPE_CLASS_NUMBER) { try { value.writeFrom(it) textView.text = value.value().toString() } catch (e: NumberFormatException) { - longToast(SharedContext.translation["config_activity.invalid_number_toast"]) + itemHelper.longToast(SharedContext.translation["config_activity.invalid_number_toast"], this) } } return@setOnClickListener } - askForValue(property, InputType.TYPE_CLASS_TEXT) { + itemHelper.askForValue(property, InputType.TYPE_CLASS_TEXT) { value.writeFrom(it) textView.text = value.value().toString() } @@ -210,7 +208,7 @@ class ConfigActivity : Activity() { addValueView(textView) } is ConfigStateListValue -> { - val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = false) + val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false) val values = value.value() fun updateText() { @@ -222,13 +220,13 @@ class ConfigActivity : Activity() { configItem.setOnClickListener { AlertDialog.Builder(this) .setTitle(propertyName) - .setPositiveButton(positiveButtonText) { _, _ -> + .setPositiveButton(itemHelper.positiveButtonText) { _, _ -> updateText() } .setMultiChoiceItems( values.keys.map { if (property.disableValueLocalization) it - else SharedContext.translation["option.property." + property.translationKey + "." + it] + else SharedContext.translation[property.getOptionTranslationKey(it)] }.toTypedArray(), values.map { it.value }.toBooleanArray() ) { _, which, isChecked -> @@ -240,7 +238,7 @@ class ConfigActivity : Activity() { addValueView(textView) } is ConfigStateSelection -> { - val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = true) + val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = true) textView.text = value.value() configItem.setOnClickListener { @@ -250,14 +248,14 @@ class ConfigActivity : Activity() { builder.setSingleChoiceItems( value.keys().toTypedArray().map { if (property.disableValueLocalization) it - else SharedContext.translation["option.property." + property.translationKey + "." + it] + else SharedContext.translation[property.getOptionTranslationKey(it)] }.toTypedArray(), value.keys().indexOf(value.value()) ) { _, which -> value.writeFrom(value.keys()[which]) } - builder.setPositiveButton(positiveButtonText) { _, _ -> + builder.setPositiveButton(itemHelper.positiveButtonText) { _, _ -> textView.text = value.value() } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DebugSettingsLayoutInflater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DebugSettingsLayoutInflater.kt @@ -11,8 +11,9 @@ import android.widget.TextView import android.widget.Toast import me.rhunk.snapenhance.R import me.rhunk.snapenhance.SharedContext -import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType +import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.ui.config.ConfigActivity +import me.rhunk.snapenhance.ui.spoof.DeviceSpooferActivity import java.io.File class ActionListAdapter( @@ -70,6 +71,9 @@ class DebugSettingsLayoutInflater( add(SharedContext.translation["config_activity.title"] to { activity.startActivity(Intent(activity, ConfigActivity::class.java)) }) + add(SharedContext.translation["spoof_activity.title"] to { + activity.startActivity(Intent(activity, DeviceSpooferActivity::class.java)) + }) add(debugSettingsTranslation["clear_cache_title"] to { context.cacheDir.listFiles()?.forEach { it.deleteRecursively() 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 @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.ui.download +import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -15,12 +16,13 @@ import android.widget.Toast import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.Adapter -import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.R import me.rhunk.snapenhance.SharedContext import me.rhunk.snapenhance.data.FileType @@ -28,13 +30,16 @@ import me.rhunk.snapenhance.download.data.PendingDownload import me.rhunk.snapenhance.download.enums.DownloadStage import me.rhunk.snapenhance.util.snap.PreviewUtils import java.io.File +import java.io.FileInputStream import java.net.URL import kotlin.concurrent.thread +import kotlin.coroutines.coroutineContext class DownloadListAdapter( private val activity: DownloadManagerActivity, private val downloadList: MutableList<PendingDownload> ): Adapter<DownloadListAdapter.ViewHolder>() { + private val coroutineScope = CoroutineScope(Dispatchers.IO) private val previewJobs = mutableMapOf<Int, Job>() inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { @@ -62,32 +67,59 @@ class DownloadListAdapter( return downloadList.size } - @OptIn(DelicateCoroutinesApi::class) - private fun handlePreview(download: PendingDownload, holder: ViewHolder) { - download.outputFile?.let { File(it) }?.takeIf { it.exists() }?.let { - GlobalScope.launch(Dispatchers.IO) { - val previewBitmap = PreviewUtils.createPreviewFromFile(it)?.let { preview -> - val offsetY = (preview.height / 2 - holder.viewHeight / 2).coerceAtLeast(0) - - Bitmap.createScaledBitmap( - Bitmap.createBitmap(preview, 0, offsetY, - preview.width.coerceAtMost(holder.viewWidth), - preview.height.coerceAtMost(holder.viewHeight) - ), - holder.viewWidth, - holder.viewHeight, - false - ) - }?: return@launch - - if (coroutineContext.job.isCancelled) return@launch - Handler(holder.view.context.mainLooper).post { - holder.view.background = RoundedBitmapDrawableFactory.create(holder.view.context.resources, previewBitmap).also { - it.cornerRadius = holder.radius.toFloat() + @SuppressLint("Recycle") + private suspend fun handlePreview(download: PendingDownload, holder: ViewHolder) { + download.outputFile?.let { + val uri = Uri.parse(it) + runCatching { + if (uri.scheme == "content") { + val fileType = activity.contentResolver.openInputStream(uri)!!.use { stream -> + FileType.fromInputStream(stream) } + fileType to activity.contentResolver.openInputStream(uri) + } else { + FileType.fromFile(File(it)) to FileInputStream(it) + } + }.getOrNull() + }?.also { (fileType, assetStream) -> + val previewBitmap = assetStream?.use { stream -> + //don't preview files larger than 30MB + if (stream.available() > 30 * 1024 * 1024) return@also + + val tempFile = File.createTempFile("preview", ".${fileType.fileExtension}") + tempFile.outputStream().use { output -> + stream.copyTo(output) + } + runCatching { + PreviewUtils.createPreviewFromFile(tempFile)?.let { preview -> + val offsetY = (preview.height / 2 - holder.viewHeight / 2).coerceAtLeast(0) + + Bitmap.createScaledBitmap( + Bitmap.createBitmap( + preview, 0, offsetY, + preview.width.coerceAtMost(holder.viewWidth), + preview.height.coerceAtMost(holder.viewHeight) + ), + holder.viewWidth, + holder.viewHeight, + false + ) + } + }.onFailure { + Logger.error("failed to create preview $fileType", it) + }.also { + tempFile.delete() + }.getOrNull() + } ?: return@also + + if (coroutineContext.job.isCancelled) return@also + Handler(holder.view.context.mainLooper).post { + holder.view.background = RoundedBitmapDrawableFactory.create( + holder.view.context.resources, + previewBitmap + ).also { + it.cornerRadius = holder.radius.toFloat() } - }.also { job -> - previewJobs[holder.hashCode()] = job } } } @@ -96,7 +128,11 @@ class DownloadListAdapter( holder.status.text = download.downloadStage.toString() holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background) - handlePreview(download, holder) + coroutineScope.launch { + withTimeout(2000) { + handlePreview(download, holder) + } + } val isSaved = download.downloadStage == DownloadStage.SAVED //if the download is in progress, the user can cancel it @@ -129,7 +165,7 @@ class DownloadListAdapter( holder.bitmojiIcon.setImageResource(R.drawable.bitmoji_blank) - pendingDownload.iconUrl?.let { url -> + pendingDownload.metadata.iconUrl?.let { url -> thread(start = true) { runCatching { val iconBitmap = URL(url).openStream().use { @@ -145,12 +181,12 @@ class DownloadListAdapter( holder.title.visibility = View.GONE holder.subtitle.visibility = View.GONE - pendingDownload.mediaDisplayType?.let { + pendingDownload.metadata.mediaDisplayType?.let { holder.title.text = it holder.title.visibility = View.VISIBLE } - pendingDownload.mediaDisplaySource?.let { + pendingDownload.metadata.mediaDisplaySource?.let { holder.subtitle.text = it holder.subtitle.visibility = View.VISIBLE } @@ -165,13 +201,38 @@ class DownloadListAdapter( } pendingDownload.outputFile?.let { - val file = File(it) - if (!file.exists()) { - Toast.makeText(holder.view.context, activity.translation["file_not_found_toast"], Toast.LENGTH_SHORT).show() + fun showFileNotFound() { + Toast.makeText(holder.view.context, SharedContext.translation["download_manager_activity.file_not_found_toast"], Toast.LENGTH_SHORT).show() + } + + val uri = Uri.parse(it) + val fileType = runCatching { + if (uri.scheme == "content") { + activity.contentResolver.openInputStream(uri)?.use { input -> + FileType.fromInputStream(input) + } ?: run { + showFileNotFound() + return@setOnClickListener + } + } else { + val file = File(it) + if (!file.exists()) { + showFileNotFound() + return@setOnClickListener + } + FileType.fromFile(file) + } + }.onFailure { exception -> + Logger.error("Failed to open file", exception) + }.getOrDefault(FileType.UNKNOWN) + if (fileType == FileType.UNKNOWN) { + showFileNotFound() return@setOnClickListener } + val intent = Intent(Intent.ACTION_VIEW) - intent.setDataAndType(Uri.parse(it), FileType.fromFile(File(it)).mimeType) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + intent.setDataAndType(uri, fileType.mimeType) holder.view.context.startActivity(intent) } } 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 @@ -17,8 +17,8 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.bridge.TranslationWrapper import me.rhunk.snapenhance.SharedContext +import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper import me.rhunk.snapenhance.download.data.PendingDownload class DownloadManagerActivity : Activity() { @@ -133,7 +133,7 @@ class DownloadManagerActivity : Activity() { if (lastVisibleItemPosition == fetchedDownloadTasks.size - 1 && !isLoading) { isLoading = true - SharedContext.downloadTaskManager.queryTasks(fetchedDownloadTasks.last().id, filter = listFilter).forEach { + SharedContext.downloadTaskManager.queryTasks(fetchedDownloadTasks.last().downloadId, filter = listFilter).forEach { fetchedDownloadTasks.add(it.value) adapter?.notifyItemInserted(fetchedDownloadTasks.size - 1) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/ViewAppearanceHelper.kt @@ -1,93 +0,0 @@ -package me.rhunk.snapenhance.ui.menu - -import android.annotation.SuppressLint -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.StateListDrawable -import android.view.Gravity -import android.view.View -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.Constants - -object ViewAppearanceHelper { - @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", - "ClickableViewAccessibility" - ) - private var sigColorTextPrimary: Int = 0 - private var sigColorBackgroundSurface: Int = 0 - - private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { - if (!hasRadius) return ColorDrawable(color) - //FIXME: hardcoded radius - return ShapeDrawable().apply { - paint.color = color - shape = android.graphics.drawable.shapes.RoundRectShape( - floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), - null, - null - ) - } - } - - @SuppressLint("DiscouragedApi") - fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false) { - val resources = component.context.resources - if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { - with(component.context.theme) { - sigColorTextPrimary = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - - sigColorBackgroundSurface = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - } - } - - val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android") - val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 - - with(component) { - if (this is TextView) { - setTextColor(sigColorTextPrimary) - setShadowLayer(0F, 0F, 0F, 0) - gravity = Gravity.CENTER_VERTICAL - componentWidth?.let { width = it} - height = (150 * scalingFactor).toInt() - isAllCaps = false - textSize = 16f - typeface = resources.getFont(snapchatFontResId) - outlineProvider = null - setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) - } - background = StateListDrawable().apply { - addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, hasRadius)) - addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, hasRadius)) - } - } - - if (component is Switch) { - with(resources) { - component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() - } - component.trackTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#1d1d1d"), - Color.parseColor("#26bd49") - ) - ) - component.thumbTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#F5F5F5"), - Color.parseColor("#26bd49") - ) - ) - } - } -} 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 @@ -7,13 +7,15 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.Button +import android.widget.LinearLayout +import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.AbstractMenu -import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper class ChatActionMenu : AbstractMenu() { @@ -23,7 +25,7 @@ class ChatActionMenu : AbstractMenu() { return false } - @SuppressLint("SetTextI18n") + @SuppressLint("SetTextI18n", "DiscouragedApi") fun inject(viewGroup: ViewGroup) { val parent = viewGroup.parent.parent as ViewGroup if (wasInjectedView(parent)) return @@ -41,17 +43,64 @@ class ChatActionMenu : AbstractMenu() { ) } + val defaultGap = viewGroup.resources.getDimensionPixelSize( + viewGroup.resources.getIdentifier( + "default_gap", + "dimen", + Constants.SNAPCHAT_PACKAGE_NAME + ) + ) + + val chatActionMenuItemMargin = viewGroup.resources.getDimensionPixelSize( + viewGroup.resources.getIdentifier( + "chat_action_menu_item_margin", + "dimen", + Constants.SNAPCHAT_PACKAGE_NAME + ) + ) + + val actionMenuItemHeight = viewGroup.resources.getDimensionPixelSize( + viewGroup.resources.getIdentifier( + "action_menu_item_height", + "dimen", + Constants.SNAPCHAT_PACKAGE_NAME + ) + ) + + val buttonContainer = LinearLayout(viewGroup.context).apply layout@{ + orientation = LinearLayout.VERTICAL + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + ViewAppearanceHelper.applyTheme(this@layout, parent.width, true) + setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) + } + } + val injectButton = { button: Button -> - ViewAppearanceHelper.applyTheme(button, parent.width, hasRadius = true) + if (buttonContainer.childCount > 0) { + buttonContainer.addView(View(viewGroup.context).apply { + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + height = 1 + } + setBackgroundColor(0x1A000000) + }) + } + + ViewAppearanceHelper.applyTheme(button, parent.width, true) with(button) { layoutParams = MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ).apply { - setMargins(40, 0, 40, 15) + height = actionMenuItemHeight + defaultGap } - parent.addView(this) + buttonContainer.addView(this) } } @@ -91,5 +140,7 @@ class ChatActionMenu : AbstractMenu() { } }) } + + parent.addView(buttonContainer) } } 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 @@ -1,7 +1,6 @@ package me.rhunk.snapenhance.ui.menu.impl import android.annotation.SuppressLint -import android.app.AlertDialog import android.content.Context import android.content.DialogInterface import android.content.res.Resources @@ -32,8 +31,8 @@ import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave +import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.AbstractMenu -import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper import me.rhunk.snapenhance.util.snap.BitmojiSelfie import java.net.HttpURLConnection import java.net.URL @@ -73,7 +72,7 @@ class FriendFeedInfoMenu : AbstractMenu() { val finalIcon = icon context.runOnUiThread { val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) - val builder = AlertDialog.Builder(context.mainActivity) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) builder.setIcon(finalIcon) builder.setTitle(profile.displayName ?: profile.username) @@ -168,7 +167,7 @@ class FriendFeedInfoMenu : AbstractMenu() { //alert dialog - val builder = AlertDialog.Builder(context.mainActivity) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) builder.setTitle(context.translation["conversation_preview.title"]) builder.setMessage(messageBuilder.toString()) builder.setPositiveButton( @@ -200,10 +199,20 @@ class FriendFeedInfoMenu : AbstractMenu() { return BitmapDrawable(context.resources, bitmap) } - private fun getCurrentConversationId(): Pair<String, String?> { + private fun getCurrentConversationInfo(): Pair<String, String?> { val messaging = context.feature(Messaging::class) val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString() + //mapped conversation fetch (may not work with legacy sc versions) + messaging.lastFetchGroupConversationUUID?.let { + context.database.getFriendFeedInfoByConversationId(it.toString())?.let { friendFeedInfo -> + val participantSize = friendFeedInfo.participantsSize + return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null + } + throw IllegalStateException("No conversation found") + } + + //old conversation fetch val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) { val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found") conversation.client_conversation_id!!.trim().lowercase() @@ -211,7 +220,7 @@ class FriendFeedInfoMenu : AbstractMenu() { messaging.lastFetchConversationUUID.toString() } - return Pair(conversationId, focusedConversationTargetUser) + return conversationId to focusedConversationTargetUser } private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { @@ -234,7 +243,7 @@ class FriendFeedInfoMenu : AbstractMenu() { val friendFeedMenuOptions = context.config.options(ConfigProperty.FRIEND_FEED_MENU_BUTTONS) if (friendFeedMenuOptions.none { it.value }) return - val (conversationId, targetUser) = getCurrentConversationId() + val (conversationId, targetUser) = getCurrentConversationInfo() if (!context.config.bool(ConfigProperty.ENABLE_FRIEND_FEED_MENU_BAR)) { //preview button diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt @@ -28,14 +28,6 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) } - private val fetchConversationHooks = mutableSetOf<Unhook>() - - private fun unhookFetchConversation() { - fetchConversationHooks.let { - it.removeIf { hook -> hook.unhook() ; true} - } - } - @SuppressLint("ResourceType") override fun asyncOnActivityCreate() { friendFeedInfoMenu.context = context @@ -86,19 +78,25 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar } //TODO: inject in group chat menus - if (viewGroup.id == actionSheetContainer && childView.id == actionMenu) { + if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchGroupConversationUUID != null) { val injectedLayout = LinearLayout(childView.context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.BOTTOM layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) addView(childView) + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + messaging.lastFetchGroupConversationUUID = null + } + }) } - fun injectView() { - val viewList = mutableListOf<View>() + val viewList = mutableListOf<View>() + context.runOnUiThread { friendFeedInfoMenu.inject(injectedLayout) { view -> view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 10, 0, 10) + setMargins(0, 3, 0, 3) } viewList.add(view) } 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 @@ -11,7 +11,7 @@ import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.ui.menu.AbstractMenu -import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme +import me.rhunk.snapenhance.ui.ViewAppearanceHelper.applyTheme @SuppressLint("DiscouragedApi") class OperaContextActionMenu : AbstractMenu() { 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 @@ -18,7 +18,7 @@ import me.rhunk.snapenhance.config.impl.ConfigStateSelection import me.rhunk.snapenhance.config.impl.ConfigStateValue import me.rhunk.snapenhance.config.impl.ConfigStringValue import me.rhunk.snapenhance.ui.menu.AbstractMenu -import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper +import me.rhunk.snapenhance.ui.ViewAppearanceHelper class SettingsMenu : AbstractMenu() { @SuppressLint("ClickableViewAccessibility") @@ -48,14 +48,14 @@ class SettingsMenu : AbstractMenu() { if (property.disableValueLocalization) { it } else { - context.translation["option.property." + property.translationKey + "." + it] + context.translation[property.getOptionTranslationKey(it)] } } }) } val textEditor: ((String) -> Unit) -> Unit = { updateValue -> - val builder = AlertDialog.Builder(context.mainActivity!!) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) builder.setTitle(propertyName) val input = EditText(context.androidContext) @@ -121,13 +121,13 @@ class SettingsMenu : AbstractMenu() { updateLocalizedText(button, property.valueContainer.value()) button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(context.mainActivity!!) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) builder.setTitle(propertyName) builder.setSingleChoiceItems( property.valueContainer.keys().toTypedArray().map { if (property.disableValueLocalization) it - else context.translation["option.property." + property.translationKey + "." + it] + else context.translation[property.getOptionTranslationKey(it)] }.toTypedArray(), property.valueContainer.keys().indexOf(property.valueContainer.value()) ) { _, which -> @@ -148,7 +148,7 @@ class SettingsMenu : AbstractMenu() { updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(context.mainActivity!!) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) builder.setTitle(propertyName) val sortedStates = property.valueContainer.value().toSortedMap() @@ -156,7 +156,7 @@ class SettingsMenu : AbstractMenu() { builder.setMultiChoiceItems( sortedStates.toSortedMap().map { if (property.disableValueLocalization) it.key - else context.translation["option.property." + property.translationKey + "." + it.key] + else context.translation[property.getOptionTranslationKey(it.key)] }.toTypedArray(), sortedStates.map { it.value }.toBooleanArray() ) { _, which, isChecked -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/spoof/DeviceSpooferActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/spoof/DeviceSpooferActivity.kt @@ -0,0 +1,111 @@ +package me.rhunk.snapenhance.ui.spoof + +import android.app.Activity +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.InputType +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.Switch +import android.widget.TextView +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.SharedContext +import me.rhunk.snapenhance.config.ConfigCategory +import me.rhunk.snapenhance.config.impl.ConfigIntegerValue +import me.rhunk.snapenhance.config.impl.ConfigStateValue +import me.rhunk.snapenhance.config.impl.ConfigStringValue +import me.rhunk.snapenhance.ui.ItemHelper + +class DeviceSpooferActivity: Activity() { + private val itemHelper = ItemHelper(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + SharedContext.ensureInitialized(this) + setContentView(R.layout.device_spoofer_activity) + + findViewById<TextView>(R.id.title).text = "Device Spoofer" + findViewById<ImageButton>(R.id.back_button).setOnClickListener { finish() } + val propertyListLayout = findViewById<ViewGroup>(R.id.spoof_property_list) + + SharedContext.config.entries().filter { it.key.category == ConfigCategory.DEVICE_SPOOFER }.forEach { (property, value) -> + val configItem = layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false) + + val propertyName = SharedContext.translation["property.${property.translationKey}.name"] + + fun addSeparator() { + //add separator + propertyListLayout.addView(View(this).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1) + setBackgroundColor(getColor(R.color.tertiaryBackground)) + }) + } + + configItem.findViewById<TextView>(R.id.name).text = propertyName + configItem.findViewById<TextView>(R.id.description).also { + it.text = SharedContext.translation["property.${property.translationKey}.description"] + it.visibility = if (it.text.isEmpty()) View.GONE else View.VISIBLE + } + + fun addValueView(view: View) { + configItem.findViewById<ViewGroup>(R.id.value).addView(view) + } + + when (value) { + is ConfigStateValue -> { + val switch = Switch(this) + switch.isChecked = value.value() + switch.trackTintList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked) + ), + intArrayOf( + switch.highlightColor, + getColor(R.color.tertiaryBackground) + ) + ) + switch.setOnCheckedChangeListener { _, isChecked -> + value.writeFrom(isChecked.toString()) + } + configItem.setOnClickListener { switch.toggle() } + addValueView(switch) + } + is ConfigStringValue, is ConfigIntegerValue -> { + val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false).also { + it.text = value.value().toString() + } + configItem.setOnClickListener { + if (value is ConfigIntegerValue) { + itemHelper.askForValue(property, InputType.TYPE_CLASS_NUMBER) { + try { + value.writeFrom(it) + textView.text = value.value().toString() + } catch (e: NumberFormatException) { + itemHelper.longToast(SharedContext.translation["config_activity.invalid_number_toast"], this) + } + } + return@setOnClickListener + } + itemHelper.askForValue(property, InputType.TYPE_CLASS_TEXT) { + value.writeFrom(it) + textView.text = value.value().toString() + } + } + addValueView(textView) + } + } + + propertyListLayout.addView(configItem) + addSeparator() + } + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun onBackPressed() { + super.onBackPressed() + finish() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/ActivityResultCallback.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/ActivityResultCallback.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.util + +import android.content.Intent + +typealias ActivityResultCallback = (requestCode: Int, resultCode: Int, data: Intent?) -> Unit+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/AndroidCompatExtensions.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/AndroidCompatExtensions.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.util + +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ApplicationInfoFlags +import android.os.Build + +fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getApplicationInfo(packageName, ApplicationInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + getApplicationInfo(packageName, flags) + } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt @@ -20,6 +20,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.database.objects.FriendFeedInfo import me.rhunk.snapenhance.database.objects.FriendInfo +import me.rhunk.snapenhance.util.getApplicationInfoCompat import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper @@ -190,7 +191,7 @@ class MessageExporter( runCatching { ZipFile( - context.androidContext.packageManager.getApplicationInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA).publicSourceDir + context.androidContext.packageManager.getApplicationInfoCompat(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA).publicSourceDir ).use { apkFile -> //export rawinflate.js apkFile.getEntry("assets/web/rawinflate.js").let { entry -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.download.data.SplitMediaAssetType import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.protobuf.ProtoReader import java.io.ByteArrayInputStream @@ -15,9 +16,6 @@ import java.io.InputStream import java.util.concurrent.Executors import java.util.zip.ZipInputStream -enum class MediaType { - ORIGINAL, OVERLAY -} object MediaDownloaderHelper { fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { @@ -32,7 +30,7 @@ object MediaDownloaderHelper { } } - fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<MediaType, ByteArray> { + fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<SplitMediaAssetType, ByteArray> { val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") val content = decryptionCallback(inputStream).readBytes() val fileType = FileType.fromByteArray(content) @@ -55,10 +53,10 @@ object MediaDownloaderHelper { } videoData ?: throw FileNotFoundException("Unable to find video file in zip file") overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file") - return mapOf(MediaType.ORIGINAL to videoData, MediaType.OVERLAY to overlayData) + return mapOf(SplitMediaAssetType.ORIGINAL to videoData, SplitMediaAssetType.OVERLAY to overlayData) } - return mapOf(MediaType.ORIGINAL to content) + return mapOf(SplitMediaAssetType.ORIGINAL to content) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt @@ -8,7 +8,6 @@ import android.media.MediaDataSource import android.media.MediaMetadataRetriever import me.rhunk.snapenhance.data.FileType import java.io.File -import kotlin.math.roundToInt object PreviewUtils { fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { diff --git a/app/src/main/res/layout/device_spoofer_activity.xml b/app/src/main/res/layout/device_spoofer_activity.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:orientation="vertical" + android:background="@color/primaryBackground" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <include + android:id="@+id/title_bar" + layout="@layout/activity_default_header" /> + + <ScrollView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + + <LinearLayout + android:id="@+id/spoof_property_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + </LinearLayout> + + </ScrollView> + +</LinearLayout>+ \ No newline at end of file diff --git a/build.gradle b/build.gradle @@ -1,10 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.21' apply false -} - -tasks.register('clean', Delete) { - delete rootProject.buildDir -}- \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts @@ -0,0 +1,8 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlinAndroid) apply false +} + +true // Needed to make the Suppress annotation work for the plugins block+ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +[versions] +agp = "8.1.0" +junit = "4.13.2" +kotlin = "1.8.21" +kotlinx-coroutines-android = "1.7.2" +kotlin-reflect = "1.8.21" +recyclerview = "1.3.0" +gson = "2.10.1" +ffmpeg-kit = "5.1.LTS" +osmdroid-android = "6.1.16" +okhttp = "5.0.0-alpha.11" +dexlib2 = "2.5.2" +androidx-documentfile = "1.1.0-alpha01" + + +[libraries] +coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin-reflect" } +recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +ffmpeg-kit = { group = "com.arthenica", name = "ffmpeg-kit-full-gpl", version.ref = "ffmpeg-kit" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid-android" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +dexlib2 = { group = "org.smali", name = "dexlib2", version.ref = "dexlib2" } +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" } + + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + + +[bundles] + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri May 12 21:23:16 CEST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/mapper/.gitignore b/mapper/.gitignore @@ -0,0 +1 @@ +build/+ \ No newline at end of file diff --git a/mapper/build.gradle.kts b/mapper/build.gradle.kts @@ -0,0 +1,21 @@ +@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed +plugins { + id("com.android.library") + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = "me.rhunk.snapenhance.mapper" + compileSdk = 33 + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(libs.gson) + implementation(libs.coroutines) + implementation(libs.dexlib2) + testImplementation(libs.junit) +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/AbstractClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/AbstractClassMapper.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapmapper + +import kotlin.reflect.KClass + +abstract class AbstractClassMapper( + vararg val dependsOn: KClass<out AbstractClassMapper> = arrayOf() +) { + abstract fun run(context: MapperContext) +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/Mapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/Mapper.kt @@ -0,0 +1,90 @@ +package me.rhunk.snapmapper + +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.jf.dexlib2.Opcodes +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.ClassDef +import java.io.BufferedInputStream +import java.io.InputStream +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import kotlin.reflect.KClass + +class Mapper( + private vararg val mappers: KClass<out AbstractClassMapper> = arrayOf() +) { + private val classes = mutableListOf<ClassDef>() + fun loadApk(path: String) { + val apkFile = ZipFile(path) + val apkEntries = apkFile.entries().toList() + + fun readClass(stream: InputStream) = runCatching { + classes.addAll( + DexBackedDexFile.fromInputStream(Opcodes.getDefault(), BufferedInputStream(stream)).classes + ) + }.onFailure { + throw Throwable("Failed to load dex file", it) + } + + fun filterDexClasses(name: String) = name.startsWith("classes") && name.endsWith(".dex") + + apkEntries.firstOrNull { it.name.endsWith("lspatch/origin.apk") }?.let { origin -> + val originApk = ZipInputStream(apkFile.getInputStream(origin)) + var nextEntry = originApk.nextEntry + while (nextEntry != null) { + if (filterDexClasses(nextEntry.name)) { + readClass(originApk) + } + originApk.closeEntry() + nextEntry = originApk.nextEntry + } + return + } + + apkEntries.toList().filter { filterDexClasses(it.name) }.forEach { + readClass(apkFile.getInputStream(it)) + } + } + + fun start(): JsonObject { + val mappers = mappers.map { it.java.constructors.first().newInstance() as AbstractClassMapper } + val context = MapperContext(classes.associateBy { it.type }) + + runBlocking { + withContext(Dispatchers.IO) { + val finishedJobs = mutableListOf<Class<*>>() + val dependentsMappers = mappers.filter { it.dependsOn.isNotEmpty() } + + fun onJobFinished(mapper: AbstractClassMapper) { + finishedJobs.add(mapper.javaClass) + + dependentsMappers.filter { it -> + !finishedJobs.contains(it.javaClass) && + it.dependsOn.all { + finishedJobs.contains(it.java) + } + }.forEach { + launch { + it.run(context) + onJobFinished(it) + } + } + } + + mappers.forEach { mapper -> + if (mapper.dependsOn.isNotEmpty()) return@forEach + launch { + mapper.run(context) + onJobFinished(mapper) + } + } + } + } + + return context.exportToJson() + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/MapperContext.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/MapperContext.kt @@ -0,0 +1,58 @@ +package me.rhunk.snapmapper + +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import org.jf.dexlib2.iface.ClassDef + +class MapperContext( + private val classMap: Map<String, ClassDef> +) { + val classes: Collection<ClassDef> + get() = classMap.values + + fun getClass(name: String?): ClassDef? { + if (name == null) return null + return classMap[name] + } + + fun getClass(name: CharSequence?): ClassDef? { + if (name == null) return null + return classMap[name.toString()] + } + + private val mappings = mutableMapOf<String, Any>() + + fun addMapping(key: String, vararg array: Pair<String, Any>) { + mappings[key] = array.toMap() + } + + fun addMapping(key: String, value: String) { + mappings[key] = value + } + + fun getStringMapping(key: String): String? { + return mappings[key] as? String + } + + fun getMapMapping(key: String): Map<*, *>? { + return mappings[key] as? Map<*, *> + } + + fun exportToJson(): JsonObject { + val gson = GsonBuilder().setPrettyPrinting().create() + val json = JsonObject() + for ((key, value) in mappings) { + when (value) { + is String -> json.addProperty(key, value) + is Map<*, *> -> { + val obj = JsonObject() + for ((k, v) in value) { + obj.add(k.toString(), gson.toJsonTree(v)) + } + json.add(key, obj) + } + } + } + return json + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/ext/DexClassDef.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/ext/DexClassDef.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapmapper.ext + +import org.jf.dexlib2.AccessFlags +import org.jf.dexlib2.iface.ClassDef + +fun ClassDef.isEnum(): Boolean = accessFlags and AccessFlags.ENUM.value != 0 +fun ClassDef.isAbstract(): Boolean = accessFlags and AccessFlags.ABSTRACT.value != 0 +fun ClassDef.isFinal(): Boolean = accessFlags and AccessFlags.FINAL.value != 0 + +fun ClassDef.hasStaticConstructorString(string: String): Boolean = methods.any { + it.name == "<clinit>" && it.implementation?.findConstString(string) == true +} + +fun ClassDef.hasConstructorString(string: String): Boolean = methods.any { + it.name == "<init>" && it.implementation?.findConstString(string) == true +} + +fun ClassDef.getStaticConstructor() = methods.firstOrNull { + it.name == "<clinit>" +} + +fun ClassDef.getClassName() = type.replaceFirst("L", "").replaceFirst(";", "") +fun ClassDef.getSuperClassName() = superclass?.replaceFirst("L", "")?.replaceFirst(";", "") diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/ext/DexMethod.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/ext/DexMethod.kt @@ -0,0 +1,44 @@ +package me.rhunk.snapmapper.ext + +import org.jf.dexlib2.iface.MethodImplementation +import org.jf.dexlib2.iface.instruction.formats.Instruction21c +import org.jf.dexlib2.iface.instruction.formats.Instruction22c +import org.jf.dexlib2.iface.reference.FieldReference +import org.jf.dexlib2.iface.reference.StringReference + +fun MethodImplementation.findConstString(string: String, contains: Boolean = false): Boolean = instructions.filterIsInstance(Instruction21c::class.java).any { + (it.reference as? StringReference)?.string?.let { str -> + if (contains) { + str.contains(string) + } else { + str == string + } + } == true +} + +fun MethodImplementation.getAllConstStrings(): List<String> = instructions.filterIsInstance<Instruction21c>().mapNotNull { + it.reference as? StringReference +}.map { + it.string +} + +fun MethodImplementation.searchNextFieldReference(constString: String, contains: Boolean = false): FieldReference? = this.instructions.let { + var found = false + for (instruction in it) { + if (instruction is Instruction21c && instruction.reference is StringReference) { + val str = (instruction.reference as StringReference).string + if (if (contains) str.contains(constString) else str == constString) { + found = true + } + } + + if (!found) continue + + if (instruction is Instruction22c && + instruction.reference is FieldReference + ) { + return@let (instruction.reference as FieldReference) + } + } + null +} diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/BCryptClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/BCryptClassMapper.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.getStaticConstructor +import me.rhunk.snapmapper.ext.isFinal +import org.jf.dexlib2.iface.instruction.formats.ArrayPayload + +class BCryptClassMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + if (!clazz.isFinal()) continue + + val isBcryptClass = clazz.getStaticConstructor()?.let { constructor -> + constructor.implementation?.instructions?.filterIsInstance<ArrayPayload>()?.any { it.arrayElements.size == 18 && it.arrayElements[0] == 608135816 } + } + + if (isBcryptClass == true) { + val hashMethod = clazz.methods.first { + it.parameterTypes.size == 2 && + it.parameterTypes[0] == "Ljava/lang/String;" && + it.parameterTypes[1] == "Ljava/lang/String;" && + it.returnType == "Ljava/lang/String;" + } + + context.addMapping("BCrypt", + "class" to clazz.type.replace("L", "").replace(";", ""), + "hashMethod" to hashMethod.name + ) + return + } + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.getClassName +import me.rhunk.snapmapper.ext.getSuperClassName +import me.rhunk.snapmapper.ext.isFinal + +class CallbackMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + val callbackClasses = context.classes.filter { clazz -> + if (clazz.superclass == null) return@filter false + + val superclassName = clazz.getSuperClassName()!! + if (!superclassName.endsWith("Callback") || superclassName.endsWith("\$Callback")) return@filter false + + val superClass = context.getClass(clazz.superclass) ?: return@filter false + if (superClass.isFinal()) return@filter false + + superClass.virtualMethods.any { it.name == "onError" } + }.map { + it.getSuperClassName()!!.substringAfterLast("/") to it.getClassName() + } + + context.addMapping("callbacks", *callbackClasses.toTypedArray()) + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/DefaultMediaItemMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/DefaultMediaItemMapper.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.isAbstract + +class DefaultMediaItemMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + val superClass = context.getClass(clazz.superclass) ?: continue + + if (!superClass.isAbstract() || superClass.interfaces.isEmpty() || superClass.interfaces[0] != "Ljava/lang/Comparable;") continue + if (clazz.methods.none { it.returnType == "Landroid/net/Uri;" }) continue + + val constructorParameters = clazz.directMethods.firstOrNull { it.name == "<init>" }?.parameterTypes ?: continue + if (constructorParameters.size < 6 || constructorParameters[5] != "J") continue + + context.addMapping("DefaultMediaItem", clazz.type.replace("L", "").replace(";", "")) + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/EnumMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/EnumMapper.kt @@ -0,0 +1,72 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.getClassName +import me.rhunk.snapmapper.ext.getStaticConstructor +import me.rhunk.snapmapper.ext.hasStaticConstructorString +import me.rhunk.snapmapper.ext.isEnum +import org.jf.dexlib2.Opcode +import org.jf.dexlib2.iface.Method +import org.jf.dexlib2.iface.instruction.formats.Instruction21c +import org.jf.dexlib2.iface.reference.FieldReference +import org.jf.dexlib2.iface.reference.StringReference + +class EnumMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + var enumQualityLevel : String? = null + val enums = mutableListOf<Pair<String, String>>() + + for (enumClass in context.classes) { + if (!enumClass.isEnum()) continue + + if (enumQualityLevel == null && enumClass.hasStaticConstructorString("LEVEL_MAX")) { + enumQualityLevel = enumClass.getClassName() + } + + if (enumClass.interfaces.isEmpty()) continue + + //check if it's a config enum + val serializableInterfaceClass = context.getClass(enumClass.interfaces.first()) ?: continue + if (serializableInterfaceClass.methods.none {it.name == "getName" && it.returnType == "Ljava/lang/String;" }) continue + + //find the method which returns the enum name + val getEnumMethod = enumClass.virtualMethods.firstOrNull { context.getClass(it.returnType)?.isEnum() == true } ?: continue + + //search for constant field instruction sget-object + + fun getFirstFieldReference21c(opcode: Opcode, method: Method) = method.implementation!!.instructions.firstOrNull { + it.opcode == opcode && it is Instruction21c + }.let { it as? Instruction21c }?.let { + it.reference as? FieldReference + } + + val fieldReference = getFirstFieldReference21c(Opcode.SGET_OBJECT, getEnumMethod) ?: + getFirstFieldReference21c(Opcode.SGET_OBJECT,enumClass.directMethods.first { it.name == "<init>" }) ?: continue + + //search field name in the <clinit> class + val enumClassListEnum = context.getClass(fieldReference.definingClass) ?: continue + + enumClassListEnum.getStaticConstructor()?.let { constructor -> + var lastEnumClassName = "" + constructor.implementation!!.instructions.forEach { + if (it.opcode == Opcode.CONST_STRING) { + lastEnumClassName = ((it as Instruction21c).reference as StringReference).string + return@forEach + } + + if (it.opcode == Opcode.SPUT_OBJECT && it is Instruction21c) { + val field = it.reference as? FieldReference ?: return@forEach + if (field.name != fieldReference.name || field.type != fieldReference.type) return@forEach + + enums.add(lastEnumClassName to enumClass.getClassName()) + } + } + } + } + + context.addMapping("EnumQualityLevel", enumQualityLevel!!) + + context.addMapping("enums", *enums.sortedBy { it.first }.toTypedArray()) + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendsFeedEventDispatcherMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendsFeedEventDispatcherMapper.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.findConstString +import me.rhunk.snapmapper.ext.getClassName + + +class FriendsFeedEventDispatcherMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + if (clazz.methods.count { it.name == "onClickFeed" || it.name == "onItemLongPress" } != 2) continue + val onItemLongPress = clazz.methods.first { it.name == "onItemLongPress" } + val viewHolderContainerClass = context.getClass(onItemLongPress.parameterTypes[0]) ?: continue + + val viewModelField = viewHolderContainerClass.fields.firstOrNull { field -> + val typeClass = context.getClass(field.type) ?: return@firstOrNull false + typeClass.methods.firstOrNull {it.name == "toString"}?.implementation?.findConstString("FriendFeedItemViewModel", contains = true) == true + }?.name ?: continue + + context.addMapping("FriendsFeedEventDispatcher", + "class" to clazz.getClassName(), + "viewModelField" to viewModelField + ) + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/MediaQualityLevelProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/MediaQualityLevelProviderMapper.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.isAbstract +import org.jf.dexlib2.AccessFlags + +class MediaQualityLevelProviderMapper : AbstractClassMapper(EnumMapper::class) { + override fun run(context: MapperContext) { + val mediaQualityLevelClass = context.getStringMapping("EnumQualityLevel") ?: return + + for (clazz in context.classes) { + if (!clazz.isAbstract()) continue + if (clazz.fields.none { it.accessFlags and AccessFlags.TRANSIENT.value != 0 }) continue + + clazz.methods.firstOrNull { it.returnType == "L$mediaQualityLevelClass;" }?.let { + context.addMapping("MediaQualityLevelProvider", + "class" to clazz.type.replace("L", "").replace(";", ""), + "method" to it.name + ) + } + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/OperaPageViewControllerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/OperaPageViewControllerMapper.kt @@ -0,0 +1,52 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.hasConstructorString +import me.rhunk.snapmapper.ext.hasStaticConstructorString +import me.rhunk.snapmapper.ext.isAbstract +import me.rhunk.snapmapper.ext.isEnum + +class OperaPageViewControllerMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + if (!clazz.isAbstract()) continue + if (!clazz.hasConstructorString("OperaPageViewController") || !clazz.hasStaticConstructorString("ad_product_type")) { + continue + } + + val viewStateField = clazz.fields.first { field -> + val fieldClass = context.getClass(field.type) ?: return@first false + fieldClass.isEnum() && fieldClass.hasStaticConstructorString("FULLY_DISPLAYED") + } + + val layerListField = clazz.fields.first { it.type == "Ljava/util/ArrayList;" } + + val onDisplayStateChange = clazz.methods.first { + if (it.returnType != "V" || it.parameterTypes.size != 1) return@first false + val firstParameterType = context.getClass(it.parameterTypes[0]) ?: return@first false + //check if the class contains a field with the enumViewStateClass type + firstParameterType.fields.any { field -> + field.type == viewStateField.type + } + } + + val onDisplayStateChangeGesture = clazz.methods.first { + if (it.returnType != "V" || it.parameterTypes.size != 2) return@first false + val firstParameterType = context.getClass(it.parameterTypes[0]) ?: return@first false + val secondParameterType = context.getClass(it.parameterTypes[1]) ?: return@first false + firstParameterType.isEnum() && secondParameterType.isEnum() + } + + context.addMapping("OperaPageViewController", + "class" to clazz.type.replace("L", "").replace(";", ""), + "viewStateField" to viewStateField.name, + "layerListField" to layerListField.name, + "onDisplayStateChange" to onDisplayStateChange.name, + "onDisplayStateChangeGesture" to onDisplayStateChangeGesture.name + ) + + return + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/PlatformAnalyticsCreatorMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/PlatformAnalyticsCreatorMapper.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.findConstString +import me.rhunk.snapmapper.ext.getStaticConstructor +import me.rhunk.snapmapper.ext.isEnum + +class PlatformAnalyticsCreatorMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + val firstConstructor = clazz.directMethods.firstOrNull { it.name == "<init>" } ?: continue + // 47 is the number of parameters of the constructor + // it may change in future versions + if (firstConstructor.parameters.size != 47) continue + val firstParameterClass = context.getClass(firstConstructor.parameterTypes[0]) ?: continue + if (!firstParameterClass.isEnum()) continue + if (firstParameterClass.getStaticConstructor()?.implementation?.findConstString("IN_APP_NOTIFICATION") != true) continue + + context.addMapping("PlatformAnalyticsCreator", clazz.type.replace("L", "").replace(";", "")) + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/PlusSubscriptionMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/PlusSubscriptionMapper.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.findConstString + +class PlusSubscriptionMapper : AbstractClassMapper(){ + override fun run(context: MapperContext) { + for (clazz in context.classes) { + if (clazz.directMethods.filter { it.name == "<init>" }.none { + it.parameters.size == 4 && + it.parameterTypes[0] == "I" && + it.parameterTypes[1] == "I" && + it.parameterTypes[2] == "J" && + it.parameterTypes[3] == "J" + }) continue + + val isPlusSubscriptionInfoClass = clazz.virtualMethods.firstOrNull { it.name == "toString" }?.implementation?.let { + it.findConstString("SubscriptionInfo", contains = true) && it.findConstString("expirationTimeMillis", contains = true) + } + + if (isPlusSubscriptionInfoClass == true) { + context.addMapping("SubscriptionInfoClass", clazz.type.replace("L", "").replace(";", "")) + return + } + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/ScCameraSettingsMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/ScCameraSettingsMapper.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.findConstString +import me.rhunk.snapmapper.ext.getStaticConstructor +import me.rhunk.snapmapper.ext.isEnum + +class ScCameraSettingsMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + val firstConstructor = clazz.directMethods.firstOrNull { it.name == "<init>" } ?: continue + if (firstConstructor.parameterTypes.size < 27) continue + val firstParameter = context.getClass(firstConstructor.parameterTypes[0]) ?: continue + if (!firstParameter.isEnum() || firstParameter.getStaticConstructor()?.implementation?.findConstString("CONTINUOUS_PICTURE") != true) continue + + context.addMapping("ScCameraSettings", clazz.type.replace("L", "").replace(";", "")) + } + } +}+ \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/StoryBoostStateMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/StoryBoostStateMapper.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapmapper.impl + +import me.rhunk.snapmapper.AbstractClassMapper +import me.rhunk.snapmapper.MapperContext +import me.rhunk.snapmapper.ext.findConstString +import me.rhunk.snapmapper.ext.getStaticConstructor +import me.rhunk.snapmapper.ext.isEnum + +class StoryBoostStateMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (clazz in context.classes) { + val firstConstructor = clazz.directMethods.firstOrNull { it.name == "<init>" } ?: continue + if (firstConstructor.parameters.size != 3) continue + if (firstConstructor.parameterTypes[1] != "J" || firstConstructor.parameterTypes[2] != "J") continue + + val storyBoostEnumClass = context.getClass(firstConstructor.parameterTypes[0]) ?: continue + if (!storyBoostEnumClass.isEnum()) continue + if (storyBoostEnumClass.getStaticConstructor()?.implementation?.findConstString("NeedSubscriptionCannotSubscribe") != true) continue + + context.addMapping("StoryBoostStateClass", clazz.type.replace("L", "").replace(";", "")) + } + } +}+ \ No newline at end of file diff --git a/mapper/src/test/kotlin/android/util/Log.kt b/mapper/src/test/kotlin/android/util/Log.kt @@ -0,0 +1,27 @@ +package android.util + +object Log { + @JvmStatic + fun d(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun e(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun i(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } + + @JvmStatic + fun v(tag: String, msg: String): Int { + println("[$tag] $msg") + return 0 + } +}+ \ No newline at end of file diff --git a/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt b/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt @@ -0,0 +1,33 @@ +package me.rhunk.snapenhance.mapper.tests + +import com.google.gson.GsonBuilder +import me.rhunk.snapmapper.Mapper +import me.rhunk.snapmapper.impl.* +import org.junit.Test +import java.io.File + + +class TestMappings { + @Test + fun testMappings() { + val mapper = Mapper( + BCryptClassMapper::class, + CallbackMapper::class, + DefaultMediaItemMapper::class, + MediaQualityLevelProviderMapper::class, + EnumMapper::class, + OperaPageViewControllerMapper::class, + PlatformAnalyticsCreatorMapper::class, + PlusSubscriptionMapper::class, + ScCameraSettingsMapper::class, + StoryBoostStateMapper::class, + FriendsFeedEventDispatcherMapper::class + ) + + val gson = GsonBuilder().setPrettyPrinting().create() + val apkFile = File(System.getenv("SNAPCHAT_APK")!!) + mapper.loadApk(apkFile.absolutePath) + val result = mapper.start() + println("Mappings: ${gson.toJson(result)}") + } +} diff --git a/settings.gradle b/settings.gradle @@ -1,16 +0,0 @@ -pluginManagement { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} -rootProject.name = "SnapEnhance" -include ':app' diff --git a/settings.gradle.kts b/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "SnapEnhance" +include(":app") +include(":mapper")+ \ No newline at end of file