From a5ed05e41501dc1c1bb3f8c3d04cf80d95b8341c Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 28 Aug 2023 22:51:40 +0200 Subject: [PATCH 001/274] feat: custom camera frame rate --- core/src/main/assets/lang/en_US.json | 6 +++--- .../snapenhance/core/config/impl/Camera.kt | 4 +++- .../features/impl/ConfigurationOverride.kt | 1 - .../features/impl/tweaks/CameraTweaks.kt | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 1f92c9b76..f0f74d0d6 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -289,9 +289,9 @@ "name": "Override Picture Resolution", "description": "Overrides the picture resolution" }, - "force_highest_frame_rate": { - "name": "Force Highest Frame Rate", - "description": "Forces the highest possible frame rate" + "custom_frame_rate": { + "name": "Custom Frame Rate", + "description": "Overrides the camera frame rate" }, "force_camera_source_encoding": { "name": "Force Camera Source Encoding", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt index 78b6a3b1f..05e268d7f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt @@ -12,6 +12,8 @@ class Camera : ConfigContainer() { { addFlags(ConfigFlag.NO_TRANSLATE) } val overridePictureResolution = unique("override_picture_resolution", *CameraTweaks.resolutions.toTypedArray()) { addFlags(ConfigFlag.NO_TRANSLATE) } - val forceHighestFrameRate = boolean("force_highest_frame_rate") { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR) } + val customFrameRate = unique("custom_frame_rate", + "5", "10", "20", "25", "30", "48", "60", "90", "120" + ) { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR); addFlags(ConfigFlag.NO_TRANSLATE) } val forceCameraSourceEncoding = boolean("force_camera_source_encoding") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt index 2823df9f1..983ed0b8b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt @@ -17,7 +17,6 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, true) - overrideProperty("FORCE_CAMERA_HIGHEST_FPS", { context.config.camera.forceHighestFrameRate.get() }, true) overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, true) overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, true) overrideProperty("ENABLE_LONG_SNAP_SENDING", { context.config.global.disableSnapSplitting.get() }, true) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt index 5c8e6f672..f6dc24078 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt @@ -4,13 +4,17 @@ import android.Manifest import android.annotation.SuppressLint import android.content.ContextWrapper import android.content.pm.PackageManager +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraCharacteristics.Key import android.hardware.camera2.CameraManager +import android.util.Range import me.rhunk.snapenhance.data.wrapper.impl.ScSize 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 me.rhunk.snapenhance.hook.hookConstructor +import me.rhunk.snapenhance.util.ktx.setObjectField class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { companion object { @@ -39,6 +43,21 @@ class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACT val previewResolutionConfig = context.config.camera.overridePreviewResolution.getNullable()?.let { parseResolution(it) } val captureResolutionConfig = context.config.camera.overridePictureResolution.getNullable()?.let { parseResolution(it) } + context.config.camera.customFrameRate.getNullable()?.also { value -> + val customFrameRate = value.toInt() + CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param -> + val key = param.arg>(0) + if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) { + val fpsRanges = param.getResult() as? Array<*> ?: return@hook + fpsRanges.forEach { + val range = it as? Range<*> ?: return@forEach + range.setObjectField("mUpper", customFrameRate) + range.setObjectField("mLower", customFrameRate) + } + } + } + } + context.mappings.getMappedClass("ScCameraSettings").hookConstructor(HookStage.BEFORE) { param -> val previewResolution = ScSize(param.argNullable(2)) val captureResolution = ScSize(param.argNullable(3)) From 5bf0ee294ecd221409ac920f034d42d166d63cac Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 29 Aug 2023 00:41:50 +0200 Subject: [PATCH 002/274] feat(native): armv7 unarycall sig --- native/jni/src/library.cpp | 21 +++++++++++++++------ native/jni/src/util.h | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/native/jni/src/library.cpp b/native/jni/src/library.cpp index 4a2e32ce7..96de8a501 100644 --- a/native/jni/src/library.cpp +++ b/native/jni/src/library.cpp @@ -8,6 +8,13 @@ #include "util.h" #include "grpc.h" +#ifdef __aarch64__ +#define ARM64 true +#else +#define ARM64 false +#endif + + static native_config_t *native_config; static JavaVM *java_vm; @@ -113,9 +120,9 @@ void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { // native lib object native_lib_object = env->NewGlobalRef(clazz); native_lib_on_unary_call_method = env->GetMethodID( - env->GetObjectClass(clazz), - "onNativeUnaryCall", - "(Ljava/lang/String;[B)L" BUILD_NAMESPACE "/NativeRequestData;" + env->GetObjectClass(clazz), + "onNativeUnaryCall", + "(Ljava/lang/String;[B)L" BUILD_NAMESPACE "/NativeRequestData;" ); // load libclient.so @@ -132,9 +139,11 @@ void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { DobbyHook((void *) DobbySymbolResolver("libc.so", "fstat"), (void *) fstat_hook, (void **) &fstat_original); - //signature might change in the future (unstable for now) - auto unaryCall_func = util::find_signature(client_module.base, client_module.size, - "FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9 FD 03 00 91 FF 43 13 D1"); + auto unaryCall_func = util::find_signature( + client_module.base, client_module.size, + ARM64 ? "A8 03 1F F8 C2 00 00 94" : "0A 90 00 F0 3F F9", + ARM64 ? -0x48 : -0x38 + ); if (unaryCall_func != 0) { DobbyHook((void *) unaryCall_func, (void *) unaryCall_hook, (void **) &unaryCall_original); } else { diff --git a/native/jni/src/util.h b/native/jni/src/util.h index 0c6ed3fa4..c99fa9dee 100644 --- a/native/jni/src/util.h +++ b/native/jni/src/util.h @@ -68,7 +68,7 @@ namespace util { env->CallVoidMethod(runtime, loadLibraryMethod, classLoader, env->NewStringUTF(libName)); } - uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern) { + uintptr_t find_signature(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) { std::vector bytes; std::vector mask; for (size_t i = 0; i < pattern.size(); i += 3) { @@ -91,7 +91,7 @@ namespace util { break; } if (found) { - return module_base + i; + return module_base + i + offset; } } return 0; From 6b9e44700d15399dbfe24c76e3747518f752bca8 Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Wed, 30 Aug 2023 19:44:55 +0200 Subject: [PATCH 003/274] fix(ci): submodules --- .github/workflows/android.yml | 6 +++++- .github/workflows/release.yml | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d3637f4b8..8554236e2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,8 +11,12 @@ jobs: build: runs-on: ubuntu-latest steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + submodules: 'recursive' + - - uses: actions/checkout@v3 - name: Set up JDK 17 uses: actions/setup-java@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a13d9bacb..22f2ea1bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout repo + uses: actions/checkout@v3 + with: + submodules: 'recursive' - name: set up JDK 17 uses: actions/setup-java@v3 From 61da95f41b3716364a587a156ac8981b0c9ecf4b Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 31 Aug 2023 00:59:30 +0200 Subject: [PATCH 004/274] feat: log system - debug actions - move packages to core --- .../kotlin/me/rhunk/snapenhance/LogManager.kt | 189 +++++++++++ .../me/rhunk/snapenhance/RemoteSideContext.kt | 71 ++-- .../rhunk/snapenhance/bridge/BridgeService.kt | 35 +- .../snapenhance/download/DownloadProcessor.kt | 29 +- .../snapenhance/messaging/ModDatabase.kt | 7 +- .../rhunk/snapenhance/ui/manager/Section.kt | 4 +- .../ui/manager/data/InstallationSummary.kt | 27 +- .../ui/manager/sections/HomeSection.kt | 147 --------- .../sections/downloads/DownloadsSection.kt | 4 +- .../sections/features/FeaturesSection.kt | 2 +- .../ui/manager/sections/home/HomeSection.kt | 312 ++++++++++++++++++ .../manager/sections/home/HomeSubSection.kt | 214 ++++++++++++ .../sections/social/AddFriendDialog.kt | 15 +- .../manager/sections/social/SocialSection.kt | 4 +- .../ui/setup/screens/impl/MappingsScreen.kt | 4 +- .../setup/screens/impl/PickLanguageScreen.kt | 2 +- .../ui/setup/screens/impl/SaveFolderScreen.kt | 2 - .../ui/util/ActivityLauncherHelper.kt | 2 +- .../rhunk/snapenhance/ui/util/AlertDialogs.kt | 2 +- .../snapenhance/bridge/BridgeInterface.aidl | 33 +- core/src/main/assets/lang/en_US.json | 6 +- .../kotlin/me/rhunk/snapenhance/Constants.kt | 2 - .../kotlin/me/rhunk/snapenhance/Logger.kt | 92 ++++-- .../kotlin/me/rhunk/snapenhance/ModContext.kt | 16 +- .../me/rhunk/snapenhance/SnapEnhance.kt | 9 +- .../snapenhance/action/AbstractAction.kt | 9 +- .../me/rhunk/snapenhance/action/EnumAction.kt | 17 + .../action/impl/CheckForUpdates.kt | 19 -- .../snapenhance/action/impl/CleanCache.kt | 4 +- .../action/impl/ClearMessageLogger.kt | 10 - .../action/impl/ExportChatMessages.kt | 9 +- .../rhunk/snapenhance/action/impl/OpenMap.kt | 2 +- .../action/impl/RefreshMappings.kt | 11 - .../{ => core}/bridge/BridgeClient.kt | 18 +- .../{ => core}/bridge/FileLoaderWrapper.kt | 4 +- .../{ => core}/bridge/types/BridgeFileType.kt | 2 +- .../{ => core}/bridge/types/FileActionType.kt | 2 +- .../bridge/wrapper/LocaleWrapper.kt | 6 +- .../bridge/wrapper/MappingsWrapper.kt | 11 +- .../bridge/wrapper/MessageLoggerWrapper.kt | 2 +- .../snapenhance/core/config/ConfigObjects.kt | 2 +- .../snapenhance/core/config/ModConfig.kt | 10 +- .../core/config/impl/Experimental.kt | 3 +- .../{ => core}/database/DatabaseAccess.kt | 10 +- .../{ => core}/database/DatabaseObject.kt | 2 +- .../database/objects/ConversationMessage.kt | 4 +- .../database/objects/FriendFeedEntry.kt | 4 +- .../{ => core}/database/objects/FriendInfo.kt | 4 +- .../{ => core}/database/objects/StoryEntry.kt | 4 +- .../database/objects/UserConversationLink.kt | 4 +- .../download/DownloadManagerClient.kt | 26 +- .../download/DownloadTaskManager.kt | 10 +- .../download/data/DownloadMediaType.kt | 2 +- .../download/data/DownloadMetadata.kt | 2 +- .../download/data/DownloadObject.kt | 4 +- .../download/data/DownloadRequest.kt | 2 +- .../{ => core}/download/data/DownloadStage.kt | 2 +- .../download/data/MediaEncryptionKeyPair.kt | 2 +- .../{ => core}/download/data/MediaFilter.kt | 2 +- .../download/data/SplitMediaAssetType.kt | 2 +- .../snapenhance/core/eventbus/EventBus.kt | 7 +- .../snapenhance/features/BridgeFileFeature.kt | 2 +- .../snapenhance/features/impl/AutoUpdater.kt | 3 +- .../impl/downloader/MediaDownloader.kt | 32 +- .../downloader/ProfilePictureDownloader.kt | 3 +- .../impl/experiments/DeviceSpooferHook.kt | 7 +- .../impl/privacy/PreventMessageSending.kt | 3 +- .../impl/spying/AnonymousStoryViewing.kt | 1 - .../features/impl/spying/MessageLogger.kt | 3 +- .../features/impl/tweaks/AutoSave.kt | 2 +- .../tweaks/DisableVideoLengthRestriction.kt | 3 +- .../impl/tweaks/GooglePlayServicesDialogs.kt | 3 +- .../features/impl/tweaks/Notifications.kt | 6 +- .../features/impl/ui/PinConversations.kt | 2 +- .../snapenhance/manager/impl/ActionManager.kt | 45 ++- .../ui/menu/impl/FriendFeedInfoMenu.kt | 10 +- .../ui/menu/impl/OperaContextActionMenu.kt | 3 +- .../snapenhance/ui/menu/impl/SettingsMenu.kt | 6 +- .../snapenhance/util/SQLiteDatabaseHelper.kt | 2 +- .../snapenhance/util/download/HttpServer.kt | 14 +- .../util/download/RemoteMediaResolver.kt | 2 +- .../util/export/MessageExporter.kt | 9 +- .../util/snap/MediaDownloaderHelper.kt | 2 +- 83 files changed, 1115 insertions(+), 512 deletions(-) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/BridgeClient.kt (89%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/FileLoaderWrapper.kt (91%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/types/BridgeFileType.kt (94%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/types/FileActionType.kt (62%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/wrapper/LocaleWrapper.kt (95%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/wrapper/MappingsWrapper.kt (93%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/bridge/wrapper/MessageLoggerWrapper.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/DatabaseAccess.kt (94%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/DatabaseObject.kt (68%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/objects/ConversationMessage.kt (94%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/objects/FriendFeedEntry.kt (94%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/objects/FriendInfo.kt (96%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/objects/StoryEntry.kt (86%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/database/objects/UserConversationLink.kt (85%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/DownloadManagerClient.kt (74%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/DownloadTaskManager.kt (95%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/DownloadMediaType.kt (91%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/DownloadMetadata.kt (79%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/DownloadObject.kt (87%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/DownloadRequest.kt (92%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/DownloadStage.kt (83%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/MediaEncryptionKeyPair.kt (93%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/MediaFilter.kt (89%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/download/data/SplitMediaAssetType.kt (54%) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt new file mode 100644 index 000000000..caabcd530 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -0,0 +1,189 @@ +package me.rhunk.snapenhance + +import android.content.SharedPreferences +import android.util.Log +import java.io.File +import java.io.OutputStream +import java.io.RandomAccessFile +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.time.Duration.Companion.hours + +class LogLine( + val logLevel: LogLevel, + val dateTime: String, + val tag: String, + val message: String +) { + companion object { + fun fromString(line: String) = runCatching { + val parts = line.trimEnd().split("/") + if (parts.size != 4) return@runCatching null + LogLine( + LogLevel.fromLetter(parts[0]) ?: return@runCatching null, + parts[1], + parts[2], + parts[3] + ) + }.getOrNull() + } + + override fun toString(): String { + return "${logLevel.letter}/$dateTime/$tag/$message" + } +} + + +class LogReader( + logFile: File +) { + private val randomAccessFile = RandomAccessFile(logFile, "r") + private var startLineIndexes = mutableListOf() + var lineCount = queryLineCount() + + fun incrementLineCount() { + randomAccessFile.seek(randomAccessFile.length()) + startLineIndexes.add(randomAccessFile.filePointer) + lineCount++ + } + + private fun queryLineCount(): Int { + randomAccessFile.seek(0) + var lines = 0 + var lastIndex: Long + while (true) { + lastIndex = randomAccessFile.filePointer + randomAccessFile.readLine() ?: break + startLineIndexes.add(lastIndex) + lines++ + } + return lines + } + + private fun getLine(index: Int): String? { + if (index <= 0 || index > lineCount) return null + randomAccessFile.seek(startLineIndexes[index]) + return randomAccessFile.readLine() + } + + fun getLogLine(index: Int): LogLine? { + return getLine(index)?.let { LogLine.fromString(it) } + } +} + + +class LogManager( + remoteSideContext: RemoteSideContext +) { + companion object { + private const val TAG = "SnapEnhanceManager" + private val LOG_LIFETIME = 24.hours + } + + var lineAddListener = { _: LogLine -> } + + private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") + private val preferences: SharedPreferences + + private var logFile: File + + init { + if (!logFolder.exists()) { + logFolder.mkdirs() + } + preferences = remoteSideContext.androidContext.getSharedPreferences("logger", 0) + logFile = preferences.getString("log_file", null)?.let { File(it) }?.takeIf { it.exists() } ?: run { + newLogFile() + logFile + } + + if (System.currentTimeMillis() - preferences.getLong("last_created", 0) > LOG_LIFETIME.inWholeMilliseconds) { + newLogFile() + } + } + + private fun getCurrentDateTime(pathSafe: Boolean = false): String { + return DateTimeFormatter.ofPattern(if (pathSafe) "yyyy-MM-dd_HH-mm-ss" else "yyyy-MM-dd HH:mm:ss").format( + java.time.LocalDateTime.now() + ) + } + + private fun newLogFile() { + val currentTime = System.currentTimeMillis() + logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also { + it.createNewFile() + } + preferences.edit().putString("log_file", logFile.absolutePath).putLong("last_created", currentTime).apply() + } + + fun clearLogs() { + logFile.delete() + newLogFile() + } + + fun getLogFile() = logFile + + fun exportLogsToZip(outputStream: OutputStream) { + val zipOutputStream = ZipOutputStream(outputStream) + //add logFolder to zip + logFolder.walk().forEach { + if (it.isFile) { + zipOutputStream.putNextEntry(ZipEntry(it.name)) + it.inputStream().copyTo(zipOutputStream) + zipOutputStream.closeEntry() + } + } + + //add device info to zip + zipOutputStream.putNextEntry(ZipEntry("device_info.txt")) + + + zipOutputStream.close() + } + + fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also { + lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) } + } + + fun debug(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.DEBUG, message) + } + + fun error(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.ERROR, message) + } + + fun error(message: Any?, throwable: Throwable, tag: String = TAG) { + internalLog(tag, LogLevel.ERROR, message) + internalLog(tag, LogLevel.ERROR, throwable) + } + + fun info(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.INFO, message) + } + + fun verbose(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.VERBOSE, message) + } + + fun warn(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.WARN, message) + } + + fun assert(message: Any?, tag: String = TAG) { + internalLog(tag, LogLevel.ASSERT, message) + } + + fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { + runCatching { + val line = LogLine(logLevel, getCurrentDateTime(), tag, message.toString()) + logFile.appendText("$line\n", Charsets.UTF_8) + lineAddListener(line) + Log.println(logLevel.priority, tag, message.toString()) + }.onFailure { + Log.println(Log.ERROR, tag, "Failed to log message: $message") + Log.println(Log.ERROR, tag, it.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index a523196b3..75c50c4ca 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -3,9 +3,12 @@ package me.rhunk.snapenhance import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.core.app.CoreComponentFactory import androidx.documentfile.provider.DocumentFile import coil.ImageLoader import coil.decode.VideoFrameDecoder @@ -13,18 +16,25 @@ import coil.disk.DiskCache import coil.memory.MemoryCache import kotlinx.coroutines.Dispatchers import me.rhunk.snapenhance.bridge.BridgeService -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper -import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig -import me.rhunk.snapenhance.download.DownloadTaskManager +import me.rhunk.snapenhance.core.download.DownloadTaskManager import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder +import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.data.InstallationSummary -import me.rhunk.snapenhance.ui.manager.data.ModMappingsInfo +import me.rhunk.snapenhance.ui.manager.data.ModInfo +import me.rhunk.snapenhance.ui.manager.data.PlatformInfo import me.rhunk.snapenhance.ui.manager.data.SnapchatAppInfo import me.rhunk.snapenhance.ui.setup.Requirements import me.rhunk.snapenhance.ui.setup.SetupActivity +import java.io.ByteArrayInputStream import java.lang.ref.WeakReference +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + class RemoteSideContext( val androidContext: Context @@ -42,6 +52,7 @@ class RemoteSideContext( val downloadTaskManager = DownloadTaskManager() val modDatabase = ModDatabase(this) val streaksReminder = StreaksReminder(this) + val log = LogManager(this) //used to load bitmoji selfies and download previews val imageLoader by lazy { @@ -76,37 +87,57 @@ class RemoteSideContext( modDatabase.init() streaksReminder.init() }.onFailure { - Logger.error("Failed to load RemoteSideContext", it) + log.error("Failed to load RemoteSideContext", it) } } - fun getInstallationSummary() = InstallationSummary( - snapchatInfo = mappings.getSnapchatPackageInfo()?.let { - SnapchatAppInfo( - version = it.versionName, - versionCode = it.longVersionCode - ) - }, - mappingsInfo = if (mappings.isMappingsLoaded()) { - ModMappingsInfo( - generatedSnapchatVersion = mappings.getGeneratedBuildNumber(), - isOutdated = mappings.isMappingsOutdated() + val installationSummary by lazy { + InstallationSummary( + snapchatInfo = mappings.getSnapchatPackageInfo()?.let { + SnapchatAppInfo( + packageName = it.packageName, + version = it.versionName, + versionCode = it.longVersionCode, + isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name, + isSplitApk = it.splitNames.isNotEmpty() + ) + }, + modInfo = ModInfo( + loaderPackageName = MainActivity::class.java.`package`?.name ?: "unknown", + buildPackageName = BuildConfig.APPLICATION_ID, + buildVersion = BuildConfig.VERSION_NAME, + buildVersionCode = BuildConfig.VERSION_CODE.toLong(), + buildIssuer = androidContext.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_SIGNING_CERTIFICATES) + ?.signingInfo?.apkContentsSigners?.firstOrNull()?.let { + val certFactory = CertificateFactory.getInstance("X509") + val cert = certFactory.generateCertificate(ByteArrayInputStream(it.toByteArray())) as X509Certificate + cert.issuerDN.toString() + } ?: throw Exception("Failed to get certificate info"), + isDebugBuild = BuildConfig.DEBUG, + mappingVersion = mappings.getGeneratedBuildNumber(), + mappingsOutdated = mappings.isMappingsOutdated() + ), + platformInfo = PlatformInfo( + device = Build.DEVICE, + buildFingerprint = Build.FINGERPRINT, + androidVersion = Build.VERSION.RELEASE, + systemAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" ) - } else null - ) + ) + } fun longToast(message: Any) { androidContext.mainExecutor.execute { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() } - Logger.debug(message.toString()) + log.debug(message.toString()) } fun shortToast(message: Any) { androidContext.mainExecutor.execute { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() } - Logger.debug(message.toString()) + log.debug(message.toString()) } fun checkForRequirements(overrideRequirements: Int? = null): Boolean { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt index 56cec538d..84dd01376 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -3,16 +3,16 @@ package me.rhunk.snapenhance.bridge import android.app.Service import android.content.Intent import android.os.IBinder -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.LogLevel import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder -import me.rhunk.snapenhance.bridge.types.BridgeFileType -import me.rhunk.snapenhance.bridge.types.FileActionType -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper -import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.types.FileActionType +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.MessageLoggerWrapper +import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo -import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.util.SerializableDataObject import kotlin.system.measureTimeMillis @@ -36,7 +36,7 @@ class BridgeService : Service() { fun triggerFriendSync(friendId: String) { val syncedFriend = syncCallback.syncFriend(friendId) if (syncedFriend == null) { - Logger.error("Failed to sync friend $friendId") + remoteSideContext.log.error("Failed to sync friend $friendId") return } SerializableDataObject.fromJson(syncedFriend).let { @@ -47,7 +47,7 @@ class BridgeService : Service() { fun triggerGroupSync(groupId: String) { val syncedGroup = syncCallback.syncGroup(groupId) if (syncedGroup == null) { - Logger.error("Failed to sync group $groupId") + remoteSideContext.log.error("Failed to sync group $groupId") return } SerializableDataObject.fromJson(syncedGroup).let { @@ -56,10 +56,12 @@ class BridgeService : Service() { } inner class BridgeBinder : BridgeInterface.Stub() { + override fun broadcastLog(tag: String, level: String, message: String) { + remoteSideContext.log.internalLog(tag, LogLevel.fromShortName(level) ?: LogLevel.INFO, message) + } + override fun fileOperation(action: Int, fileType: Int, content: ByteArray?): ByteArray { - val resolvedFile by lazy { - BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) - } + val resolvedFile = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) return when (FileActionType.values()[action]) { FileActionType.CREATE_AND_READ -> { @@ -108,8 +110,6 @@ class BridgeService : Service() { override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id) - override fun clearMessageLogger() = messageLoggerWrapper.clearMessages() - override fun getApplicationApkPath(): String = applicationInfo.publicSourceDir override fun fetchLocales(userLocale: String) = @@ -133,25 +133,24 @@ class BridgeService : Service() { } override fun sync(callback: SyncCallback) { - Logger.debug("Syncing remote") syncCallback = callback measureTimeMillis { remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId -> runCatching { triggerFriendSync(friendId) }.onFailure { - Logger.error("Failed to sync friend $friendId", it) + remoteSideContext.log.error("Failed to sync friend $friendId", it) } } remoteSideContext.modDatabase.getGroups().map { it.conversationId }.forEach { groupId -> runCatching { triggerGroupSync(groupId) }.onFailure { - Logger.error("Failed to sync group $groupId", it) + remoteSideContext.log.error("Failed to sync group $groupId", it) } } }.also { - Logger.debug("Syncing remote took $it ms") + remoteSideContext.log.verbose("Syncing remote took $it ms") } } @@ -159,7 +158,7 @@ class BridgeService : Service() { groups: List, friends: List ) { - Logger.debug("Received ${groups.size} groups and ${friends.size} friends") + remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") remoteSideContext.modDatabase.receiveMessagingDataCallback( friends.map { SerializableDataObject.fromJson(it) }, groups.map { SerializableDataObject.fromJson(it) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index cd937e471..7aeec42eb 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -19,14 +19,15 @@ import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.download.data.DownloadMediaType -import me.rhunk.snapenhance.download.data.DownloadMetadata -import me.rhunk.snapenhance.download.data.DownloadObject -import me.rhunk.snapenhance.download.data.DownloadRequest -import me.rhunk.snapenhance.download.data.DownloadStage -import me.rhunk.snapenhance.download.data.InputMedia -import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.core.download.data.DownloadMediaType +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadObject +import me.rhunk.snapenhance.core.download.data.DownloadRequest +import me.rhunk.snapenhance.core.download.data.DownloadStage +import me.rhunk.snapenhance.core.download.data.InputMedia +import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import java.io.File @@ -178,14 +179,14 @@ class DownloadProcessor ( mediaScanIntent.setData(outputFile.uri) remoteSideContext.androidContext.sendBroadcast(mediaScanIntent) }.onFailure { - Logger.error("Failed to scan media file", it) + remoteSideContext.log.error("Failed to scan media file", it) callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) } - Logger.debug("download complete") + remoteSideContext.log.verbose("download complete") callbackOnSuccess(fileName) }.onFailure { exception -> - Logger.error(exception) + remoteSideContext.log.error("Failed to save media to gallery", exception) callbackOnFailure(translation.format("failed_gallery_toast", "error" to exception.toString()), exception.message) downloadObject.downloadStage = DownloadStage.FAILED } @@ -284,7 +285,7 @@ class DownloadProcessor ( saveMediaToGallery(outputFile, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure - Logger.error(exception) + remoteSideContext.log.error("Failed to download dash media", exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) downloadObjectObject.downloadStage = DownloadStage.FAILED } @@ -333,7 +334,7 @@ class DownloadProcessor ( val downloadedMedias = downloadInputMedias(downloadRequest).map { it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) }.toMap().toMutableMap() - Logger.debug("downloaded ${downloadedMedias.size} medias") + remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias") var shouldMergeOverlay = downloadRequest.shouldMergeOverlay @@ -376,7 +377,7 @@ class DownloadProcessor ( saveMediaToGallery(mergedOverlay, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure - Logger.error(exception) + remoteSideContext.log.error("Failed to merge overlay", exception) callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message) downloadObjectObject.downloadStage = DownloadStage.MERGE_FAILED } @@ -390,7 +391,7 @@ class DownloadProcessor ( downloadRemoteMedia(downloadObjectObject, downloadedMedias, downloadRequest) }.onFailure { exception -> downloadObjectObject.downloadStage = DownloadStage.FAILED - Logger.error(exception) + remoteSideContext.log.error("Failed to download media", exception) callbackOnFailure(translation["failed_generic_toast"], exception.message) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt index e87c6535d..978b5aa45 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -1,13 +1,12 @@ package me.rhunk.snapenhance.messaging import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.messaging.FriendStreaks import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getLongOrNull @@ -28,7 +27,7 @@ class ModDatabase( runCatching { block() }.onFailure { - Logger.error("Failed to execute async block", it) + context.log.error("Failed to execute async block", it) } } } @@ -103,7 +102,7 @@ class ModDatabase( selfieId = cursor.getStringOrNull("selfieId") )) }.onFailure { - Logger.error("Failed to parse friend", it) + context.log.error("Failed to parse friend", it) } } friends diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt index b6a4fba16..717ca5d6b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Section.kt @@ -13,7 +13,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.ui.manager.sections.HomeSection +import me.rhunk.snapenhance.ui.manager.sections.home.HomeSection import me.rhunk.snapenhance.ui.manager.sections.NotImplemented import me.rhunk.snapenhance.ui.manager.sections.downloads.DownloadsSection import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection @@ -64,6 +64,8 @@ open class Section { lateinit var context: RemoteSideContext lateinit var navController: NavController + val currentRoute get() = navController.currentBackStackEntry?.destination?.route + open fun init() {} open fun onResumed() {} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt index a5dcfd226..24e73e0cb 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt @@ -2,16 +2,33 @@ package me.rhunk.snapenhance.ui.manager.data data class SnapchatAppInfo( + val packageName: String, val version: String, - val versionCode: Long + val versionCode: Long, + val isLSPatched: Boolean, + val isSplitApk: Boolean, ) -data class ModMappingsInfo( - val generatedSnapchatVersion: Long, - val isOutdated: Boolean +data class ModInfo( + val loaderPackageName: String, + val buildPackageName: String, + val buildVersion: String, + val buildVersionCode: Long, + val buildIssuer: String, + val isDebugBuild: Boolean, + val mappingVersion: Long?, + val mappingsOutdated: Boolean?, +) + +data class PlatformInfo( + val device: String, + val buildFingerprint: String, + val androidVersion: String, + val systemAbi: String, ) data class InstallationSummary( + val platformInfo: PlatformInfo, val snapchatInfo: SnapchatAppInfo?, - val mappingsInfo: ModMappingsInfo? + val modInfo: ModInfo?, ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt deleted file mode 100644 index 3fd84cffd..000000000 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/HomeSection.kt +++ /dev/null @@ -1,147 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.sections - -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import me.rhunk.snapenhance.ui.manager.Section -import me.rhunk.snapenhance.ui.manager.data.InstallationSummary -import me.rhunk.snapenhance.ui.setup.Requirements -import java.util.Locale - -class HomeSection : Section() { - companion object { - val cardMargin = 10.dp - } - private val installationSummary = mutableStateOf(null as InstallationSummary?) - private val userLocale = mutableStateOf(null as String?) - - @Composable - private fun SummaryCards(installationSummary: InstallationSummary) { - //installation summary - OutlinedCard( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth() - ) { - Column(modifier = Modifier.padding(all = 16.dp)) { - if (installationSummary.snapchatInfo != null) { - Text("Snapchat version: ${installationSummary.snapchatInfo.version}") - Text("Snapchat version code: ${installationSummary.snapchatInfo.versionCode}") - } else { - Text("Snapchat not installed/detected") - } - } - } - - OutlinedCard( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(all = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon( - Icons.Filled.Map, - contentDescription = "Mappings", - modifier = Modifier - .padding(end = 10.dp) - .align(Alignment.CenterVertically) - ) - - Text(text = if (installationSummary.mappingsInfo == null || installationSummary.mappingsInfo.isOutdated) { - "Mappings ${if (installationSummary.mappingsInfo == null) "not generated" else "outdated"}" - } else { - "Mappings version ${installationSummary.mappingsInfo.generatedSnapchatVersion}" - }, modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - - //inline button - Button(onClick = { - context.checkForRequirements(Requirements.MAPPINGS) - }, modifier = Modifier.height(40.dp)) { - Icon(Icons.Filled.Refresh, contentDescription = "Refresh") - } - } - } - OutlinedCard( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(all = 16.dp), - ) { - Icon( - Icons.Filled.Language, - contentDescription = "Language", - modifier = Modifier - .padding(end = 10.dp) - .align(Alignment.CenterVertically) - ) - Text(text = userLocale.value ?: "Unknown", modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) - - //inline button - Button(onClick = { - context.checkForRequirements(Requirements.LANGUAGE) - }, modifier = Modifier.height(40.dp)) { - Icon(Icons.Filled.OpenInNew, contentDescription = null) - } - } - } - } - - override fun onResumed() { - if (!context.mappings.isMappingsLoaded()) { - context.mappings.init(context.androidContext) - } - installationSummary.value = context.getInstallationSummary() - userLocale.value = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) - } - - override fun sectionTopBarName() = "SnapEnhance" - - @Composable - @Preview - override fun Content() { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(ScrollState(0)) - ) { - Text( - text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec euismod, nisl eget ultricies ultrices, nunc nisl aliquam nunc, quis aliquam nisl nunc eu nisl. Donec euismod, nisl eget ultricies ultrices, nunc nisl aliquam nunc, quis aliquam nisl nunc eu nisl.", - modifier = Modifier.padding(16.dp) - ) - - SummaryCards(installationSummary = installationSummary.value ?: return) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt index 26f6982e6..2cd20aadb 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -51,8 +51,8 @@ import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter import kotlinx.coroutines.launch import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.download.data.DownloadObject -import me.rhunk.snapenhance.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.DownloadObject +import me.rhunk.snapenhance.core.download.data.MediaFilter import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.ImageRequestHelper diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt index b6ed768a4..7ede6f903 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -439,7 +439,7 @@ class FeaturesSection : Section() { IconButton(onClick = { showSearchBar = showSearchBar.not() - if (!showSearchBar && navController.currentBackStackEntry?.destination?.route == SEARCH_FEATURE_ROUTE) { + if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) { navController.navigate(MAIN_ROUTE) } }) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt new file mode 100644 index 000000000..6da9610a1 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -0,0 +1,312 @@ +package me.rhunk.snapenhance.ui.manager.sections.home + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.ReceiptLong +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.manager.data.InstallationSummary +import me.rhunk.snapenhance.ui.setup.Requirements +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.saveFile +import java.util.Locale + +class HomeSection : Section() { + companion object { + val cardMargin = 10.dp + const val HOME_ROOT = "home_root" + const val DEBUG_SECTION_ROUTE = "home_debug" + const val LOGS_SECTION_ROUTE = "home_logs" + } + + private val installationSummary = mutableStateOf(null as InstallationSummary?) + private val userLocale = mutableStateOf(null as String?) + private val homeSubSection by lazy { HomeSubSection(context) } + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @Composable + private fun SummaryCardRow(icon: ImageVector? = null, title: String, action: @Composable () -> Unit) { + Row( + modifier = Modifier.padding(all = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = null, + modifier = Modifier + .padding(end = 10.dp) + .align(Alignment.CenterVertically) + ) + } + Text(text = title, modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) + Column { + action() + } + } + } + + @Composable + private fun SummaryCards(installationSummary: InstallationSummary) { + OutlinedCard( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth() + ) { + SummaryCardRow( + icon = Icons.Filled.Map, + title = if (installationSummary.modInfo == null || installationSummary.modInfo.mappingsOutdated == true) { + "Mappings ${if (installationSummary.modInfo == null) "not generated" else "outdated"}" + } else { + "Mappings version ${installationSummary.modInfo.mappingVersion}" + } + ) { + Button(onClick = { + context.checkForRequirements(Requirements.MAPPINGS) + }, modifier = Modifier.height(40.dp)) { + Icon(Icons.Filled.Refresh, contentDescription = null) + } + } + + SummaryCardRow(icon = Icons.Filled.Language, title = userLocale.value ?: "Unknown") { + Button(onClick = { + context.checkForRequirements(Requirements.LANGUAGE) + }, modifier = Modifier.height(40.dp)) { + Icon(Icons.Filled.OpenInNew, contentDescription = null) + } + } + } + + val summaryInfo = remember { + mapOf( + "Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), + "Device" to installationSummary.platformInfo.device, + "Android version" to installationSummary.platformInfo.androidVersion, + "System ABI" to installationSummary.platformInfo.systemAbi, + "Build fingerprint" to installationSummary.platformInfo.buildFingerprint + ) + } + + Card( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 10.dp), + ) { + summaryInfo.forEach { (title, value) -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = 5.dp), + ) { + Text( + text = title, + fontSize = 12.sp, + fontWeight = FontWeight.Light, + ) + Text( + fontSize = 14.sp, + text = value, + lineHeight = 20.sp + ) + } + } + } + + } + } + + override fun onResumed() { + if (!context.mappings.isMappingsLoaded()) { + context.mappings.init(context.androidContext) + } + installationSummary.value = context.installationSummary + userLocale.value = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) + } + + override fun sectionTopBarName(): String { + if (currentRoute == HOME_ROOT) { + return "" + } + return context.translation["manager.routes.$currentRoute"] + } + + @Composable + override fun FloatingActionButton() { + if (currentRoute == LOGS_SECTION_ROUTE) { + homeSubSection.LogsActionButtons() + } + } + + @Composable + override fun TopBarActions(rowScope: RowScope) { + rowScope.apply { + when (currentRoute) { + HOME_ROOT -> { + IconButton(onClick = { + navController.navigate(LOGS_SECTION_ROUTE) + }) { + Icon(Icons.Filled.ReceiptLong, contentDescription = null) + } + IconButton(onClick = { + navController.navigate(DEBUG_SECTION_ROUTE) + }) { + Icon(Icons.Filled.BugReport, contentDescription = null) + } + } + LOGS_SECTION_ROUTE -> { + var showDropDown by remember { mutableStateOf(false) } + + IconButton(onClick = { + showDropDown = true + }) { + Icon(Icons.Filled.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showDropDown, + onDismissRequest = { showDropDown = false }, + modifier = Modifier.align(Alignment.CenterVertically) + ) { + DropdownMenuItem(onClick = { + context.log.clearLogs() + navController.navigate(LOGS_SECTION_ROUTE) + showDropDown = false + }, text = { + Text(text = "Clear logs") + }) + + DropdownMenuItem(onClick = { + val logFile = context.log.getLogFile() + activityLauncherHelper.saveFile(logFile.name, "text/plain") { uri -> + context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { + logFile.inputStream().copyTo(it) + context.longToast("Saved logs to $uri") + } + } + showDropDown = false + }, text = { + Text(text = "Export logs") + }) + } + } + } + } + } + + override fun build(navGraphBuilder: NavGraphBuilder) { + navGraphBuilder.navigation( + route = enumSection.route, + startDestination = HOME_ROOT + ) { + composable(HOME_ROOT) { + Content() + } + composable(LOGS_SECTION_ROUTE) { + homeSubSection.LogsSection() + } + composable(DEBUG_SECTION_ROUTE) { + homeSubSection.DebugSection() + } + } + } + + + @Composable + @Preview + override fun Content() { + Column( + modifier = Modifier + .verticalScroll(ScrollState(0)) + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.launcher_icon_monochrome), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + contentScale = ContentScale.FillHeight, + modifier = Modifier + .height(120.dp) + .scale(1.75f) + ) + Text( + text = ("\u0065" + "\u0063" + "\u006e" + "\u0061" + "\u0068" + "\u006e" + "\u0045" + "\u0070" + "\u0061" + "\u006e" + "\u0053").reversed(), + fontSize = 30.sp, + modifier = Modifier.padding(16.dp), + ) + } + + + Text( + text = "An xposed module that enhances the Snapchat experience", + modifier = Modifier.padding(16.dp) + ) + + SummaryCards(installationSummary = installationSummary.value ?: return) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt new file mode 100644 index 000000000..0e371e9fd --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -0,0 +1,214 @@ +package me.rhunk.snapenhance.ui.manager.sections.home + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown +import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.LogReader +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.action.EnumAction +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.manager.impl.ActionManager +import me.rhunk.snapenhance.ui.util.AlertDialogs + +class HomeSubSection( + private val context: RemoteSideContext +) { + private val dialogs by lazy { AlertDialogs(context.translation) } + + private lateinit var logListState: LazyListState + + @Composable + private fun RowAction(title: String, requireConfirmation: Boolean = false, action: () -> Unit) { + var confirmationDialog by remember { + mutableStateOf(false) + } + + fun takeAction() { + if (requireConfirmation) { + confirmationDialog = true + } else { + action() + } + } + + if (requireConfirmation && confirmationDialog) { + Dialog(onDismissRequest = { confirmationDialog = false }) { + dialogs.ConfirmDialog(title = "Are you sure?", onConfirm = { + action() + confirmationDialog = false + }, onDismiss = { + confirmationDialog = false + }) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(65.dp) + .clickable { + takeAction() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, modifier = Modifier.padding(start = 26.dp)) + IconButton(onClick = { takeAction() }) { + Icon( + imageVector = Icons.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + + @Composable + fun LogsSection() { + val coroutineScope = rememberCoroutineScope() + var lineCount by remember { mutableIntStateOf(0) } + var logReader by remember { mutableStateOf(null) } + logListState = remember { LazyListState(0) } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), + state = logListState + ) { + items(lineCount) { index -> + val line = logReader?.getLogLine(index) ?: return@items + Box(modifier = Modifier + .fillMaxWidth() + .background( + if (index % 2 == 0) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant + )) { + Text(text = line.message, modifier = Modifier.padding(9.dp), fontSize = 10.sp) + } + } + } + + if (logReader == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + runCatching { + logReader = context.log.newReader { + lineCount++ + } + lineCount = logReader!!.lineCount + }.onFailure { + context.longToast("Failed to read logs!") + } + } + } + } + } + + @Composable + fun LogsActionButtons() { + val coroutineScope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + FilledIconButton(onClick = { + coroutineScope.launch { + logListState.scrollToItem(0) + } + }) { + Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null) + } + + FilledIconButton(onClick = { + coroutineScope.launch { + logListState.scrollToItem(logListState.layoutInfo.totalItemsCount - 1) + } + }) { + Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null) + } + } + } + + private fun launchActionIntent(action: EnumAction) { + val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) + intent?.putExtra(ActionManager.ACTION_PARAMETER, action.key) + context.androidContext.startActivity(intent) + } + + @Composable + private fun RowTitle(title: String) { + Text(text = title, modifier = Modifier.padding(16.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + + @Composable + fun DebugSection() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(ScrollState(0)) + ) { + RowTitle(title = "Actions") + EnumAction.values().forEach { enumAction -> + RowAction(title = context.translation["actions.${enumAction.key}"]) { + launchActionIntent(enumAction) + } + } + + RowTitle(title = "Clear Files") + BridgeFileType.values().forEach { fileType -> + RowAction(title = fileType.displayName, requireConfirmation = true) { + runCatching { + fileType.resolve(context.androidContext).delete() + context.longToast("Deleted ${fileType.displayName}!") + }.onFailure { + context.longToast("Failed to delete ${fileType.displayName}!") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt index 4db58491f..37367aaa9 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt @@ -43,9 +43,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.bridge.BridgeClient +import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper @@ -55,8 +54,8 @@ class AddFriendDialog( private val section: SocialSection, ) { @Composable - private fun ListCardEntry(name: String, currentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { - var currentState by remember { mutableStateOf(currentState()) } + private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { + var currentState by remember { mutableStateOf(getCurrentState()) } Row( modifier = Modifier @@ -74,7 +73,7 @@ class AddFriendDialog( modifier = Modifier .weight(1f) .onGloballyPositioned { - currentState = currentState() + currentState = getCurrentState() } ) @@ -149,7 +148,7 @@ class AddFriendDialog( runCatching { context.androidContext.sendBroadcast(it) }.onFailure { - Logger.error("Failed to send broadcast", it) + context.log.error("Failed to send broadcast", it) hasFetchError = true } } @@ -234,7 +233,7 @@ class AddFriendDialog( val group = filteredGroups[it] ListCardEntry( name = group.name, - currentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } + getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } ) { state -> if (state) { context.bridgeService.triggerGroupSync(group.conversationId) @@ -261,7 +260,7 @@ class AddFriendDialog( ListCardEntry( name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, - currentState = { context.modDatabase.getFriendInfo(friend.userId) != null } + getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null } ) { state -> if (state) { context.bridgeService.triggerFriendSync(friend.userId) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index 0ed9ec775..77ab4d87f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -79,7 +79,7 @@ class SocialSection : Section() { groupList = context.modDatabase.getGroups() } - override fun canGoBack() = navController.currentBackStackEntry?.destination?.route != MAIN_ROUTE + override fun canGoBack() = currentRoute != MAIN_ROUTE override fun build(navGraphBuilder: NavGraphBuilder) { navGraphBuilder.navigation(route = enumSection.route, startDestination = MAIN_ROUTE) { @@ -117,7 +117,7 @@ class SocialSection : Section() { } } - if (navController.currentBackStackEntry?.destination?.route != MAIN_ROUTE) { + if (currentRoute != MAIN_ROUTE) { IconButton( onClick = { deleteConfirmDialog = true }, ) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt index ad37dfe29..7d215f194 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -44,7 +44,7 @@ class MappingsScreen : SetupScreen() { fun tryToGenerateMappings() { //check for snapchat installation - val installationSummary = context.getInstallationSummary() + val installationSummary = context.installationSummary if (installationSummary.snapchatInfo == null) { throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"]) } @@ -69,7 +69,7 @@ class MappingsScreen : SetupScreen() { }.onFailure { isGenerating = false infoText = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message - Logger.error("Failed to generate mappings", it) + context.log.error("Failed to generate mappings", it) } } }) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt index 3f0f3bb6d..8756d2a86 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PickLanguageScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.util.ObservableMutableState import java.util.Locale diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt index 3017c0038..47e4bb540 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/SaveFolderScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.ObservableMutableState @@ -23,7 +22,6 @@ class SaveFolderScreen : SetupScreen() { saveFolder = ObservableMutableState( defaultValue = "", onChange = { _, newValue -> - Logger.debug(newValue) if (newValue.isNotBlank()) { context.config.root.downloader.saveFolder.set(newValue) context.config.writeConfig() diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt index d1fe30756..009e33400 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt @@ -16,7 +16,7 @@ class ActivityLauncherHelper( runCatching { callback?.let { it(result.data!!) } }.onFailure { - Logger.error("Failed to process activity result", it) + Logger.directError("Failed to process activity result", it) } } callback = null diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt index fd11427cc..e30a39086 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.config.DataProcessors import me.rhunk.snapenhance.core.config.PropertyPair diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl index 29499ddd0..c20ac698f 100644 --- a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl +++ b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -5,53 +5,47 @@ import me.rhunk.snapenhance.bridge.DownloadCallback; import me.rhunk.snapenhance.bridge.SyncCallback; interface BridgeInterface { + /** + * broadcast a log message + */ + void broadcastLog(String tag, String level, String message); + /** * Execute a file operation + * @param fileType the corresponding file type (see BridgeFileType) */ byte[] fileOperation(int action, int fileType, in @nullable byte[] content); /** * Get the content of a logged message from the database - * - * @param conversationId the ID of the conversation - * @return the content of the message + * @return message ids that are logged */ 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(); - + * Get the application APK path (assets for the conversation exporter) + */ String getApplicationApkPath(); /** * Fetch the locales * - * @return the locale result + * @return the map of locales (key: locale short name, value: locale data as json) */ Map fetchLocales(String userLocale); @@ -62,11 +56,14 @@ interface BridgeInterface { /** * Get rules for a given user or conversation + * @return list of rules (MessagingRuleType) */ List getRules(String uuid); /** * Update rule for a giver user or conversation + * + * @param type rule type (MessagingRuleType) */ void setRule(String uuid, String type, boolean state); @@ -77,8 +74,8 @@ interface BridgeInterface { /** * Pass all groups and friends to be able to add them to the database - * @param groups serialized groups - * @param friends serialized friends + * @param groups list of groups (MessagingGroupInfo as json string) + * @param friends list of friends (MessagingFriendInfo as json string) */ oneway void passGroupsAndFriends(in List groups, in List friends); } \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index f0f74d0d6..a1062f511 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -20,6 +20,8 @@ "downloads": "Downloads", "features": "Features", "home": "Home", + "home_debug": "Debug", + "home_logs": "Logs", "social": "Social", "plugins": "Plugins" }, @@ -64,8 +66,8 @@ } }, - "action": { - "clean_cache": "Clean Cache", + "actions": { + "clean_snapchat_cache": "Clean Snapchat Cache", "clear_message_logger": "Clear Message Logger", "refresh_mappings": "Refresh Mappings", "open_map": "Choose location on map", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt index 577e0d8c3..4c9d9e14f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt @@ -1,14 +1,12 @@ package me.rhunk.snapenhance object Constants { - const val TAG = "SnapEnhance" const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" const val VIEW_INJECTED_CODE = 0x7FFFFF02 val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) val ARROYO_STRING_CHAT_MESSAGE_PROTO = ARROYO_MEDIA_CONTAINER_PROTO_PATH + intArrayOf(2, 1) - val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3) const val ENCRYPTION_PROTO_INDEX = 19 const val ENCRYPTION_PROTO_INDEX_V2 = 4 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt index f4756fffb..f78dd213f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt @@ -2,47 +2,81 @@ package me.rhunk.snapenhance import android.util.Log import de.robv.android.xposed.XposedBridge -import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.bridge.BridgeClient -object Logger { - private const val TAG = "SnapEnhance" +enum class LogLevel( + val letter: String, + val shortName: String, + val priority: Int = Log.INFO +) { + VERBOSE("V", "verbose", Log.VERBOSE), + DEBUG("D", "debug", Log.DEBUG), + INFO("I", "info", Log.INFO), + WARN("W", "warn", Log.WARN), + ERROR("E", "error", Log.ERROR), + ASSERT("A", "assert", Log.ASSERT); - fun log(message: Any?) { - Log.i(TAG, message.toString()) - } + companion object { + fun fromLetter(letter: String): LogLevel? { + return values().find { it.letter == letter } + } - fun debug(message: Any?) { - if (!BuildConfig.DEBUG) return - Log.d(TAG, message.toString()) + fun fromShortName(shortName: String): LogLevel? { + return values().find { it.shortName == shortName } + } } +} - fun debug(tag: String, message: Any?) { - if (!BuildConfig.DEBUG) return - Log.d(tag, message.toString()) - } - fun error(throwable: Throwable) { - Log.e(TAG, "", throwable) - } +class Logger( + private val bridgeClient: BridgeClient +) { + companion object { + private const val TAG = "SnapEnhanceCore" - fun error(message: Any?) { - Log.e(TAG, message.toString()) - } + fun directDebug(message: Any?, tag: String = TAG) { + Log.println(Log.DEBUG, tag, message.toString()) + } - fun error(message: Any?, throwable: Throwable) { - Log.e(TAG, message.toString(), throwable) - } + fun directError(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.ERROR, tag, message.toString()) + Log.println(Log.ERROR, tag, throwable.toString()) + } + + fun xposedLog(message: Any?, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + } - fun xposedLog(message: Any?) { - XposedBridge.log(message.toString()) + fun xposedLog(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + XposedBridge.log(throwable) + } } - fun xposedLog(message: Any?, throwable: Throwable?) { - XposedBridge.log(message.toString()) - XposedBridge.log(throwable) + private fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { + runCatching { + bridgeClient.broadcastLog(tag, logLevel.shortName, message.toString()) + }.onFailure { + Log.println(logLevel.priority, tag, message.toString()) + } } - fun xposedLog(throwable: Throwable) { - XposedBridge.log(throwable) + fun debug(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.DEBUG, message) + + fun error(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.ERROR, message) + + fun error(message: Any?, throwable: Throwable, tag: String = TAG) { + internalLog(tag, LogLevel.ERROR, message) + internalLog(tag, LogLevel.ERROR, throwable) } + + fun info(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.INFO, message) + + fun verbose(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.VERBOSE, message) + + fun warn(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.WARN, message) + + fun assert(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.ASSERT, message) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt index 468cd770d..6ede07e62 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -11,13 +11,13 @@ import android.widget.Toast import com.google.gson.Gson import com.google.gson.GsonBuilder import kotlinx.coroutines.asCoroutineDispatcher -import me.rhunk.snapenhance.bridge.BridgeClient -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper -import me.rhunk.snapenhance.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig +import me.rhunk.snapenhance.core.database.DatabaseAccess import me.rhunk.snapenhance.core.eventbus.EventBus import me.rhunk.snapenhance.data.MessageSender -import me.rhunk.snapenhance.database.DatabaseAccess import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.manager.impl.ActionManager import me.rhunk.snapenhance.manager.impl.FeatureManager @@ -44,6 +44,7 @@ class ModContext { private val modConfig = ModConfig() val config by modConfig + val log by lazy { Logger(this.bridgeClient) } val event = EventBus(this) val eventDispatcher = EventDispatcher(this) val native = NativeLib() @@ -81,13 +82,13 @@ class ModContext { } } - fun shortToast(message: Any) { + fun shortToast(message: Any?) { runOnUiThread { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() } } - fun longToast(message: Any) { + fun longToast(message: Any?) { runOnUiThread { Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() } @@ -108,7 +109,7 @@ class ModContext { } fun crash(message: String, throwable: Throwable? = null) { - Logger.xposedLog(message, throwable) + Logger.xposedLog(message, throwable ?: Exception()) longToast(message) delayForceCloseApp(100) } @@ -123,6 +124,7 @@ class ModContext { } fun reloadConfig() { + log.verbose("reloading config") modConfig.loadFromBridge(bridgeClient) native.loadNativeConfig( NativeConfig( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt index 3cd1221ec..f960cc5b4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -6,9 +6,9 @@ import android.content.Context import android.content.pm.PackageManager import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.bridge.BridgeClient import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent import me.rhunk.snapenhance.core.eventbus.events.impl.UnaryCallEvent import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo @@ -88,7 +88,7 @@ class SnapEnhance { return@hook } - Logger.debug("Reloading config") + appContext.actionManager.onNewIntent(activity.intent) appContext.reloadConfig() syncRemote() } @@ -114,7 +114,7 @@ class SnapEnhance { syncRemote() } }.also { time -> - Logger.debug("init took $time") + appContext.log.verbose("init took $time") } } @@ -126,7 +126,7 @@ class SnapEnhance { actionManager.init() } }.also { time -> - Logger.debug("onActivityCreate took $time") + appContext.log.verbose("onActivityCreate took $time") } } @@ -149,7 +149,6 @@ class SnapEnhance { val database = appContext.database appContext.executeAsync { - Logger.debug("request remote sync") appContext.bridgeClient.sync(object : SyncCallback.Stub() { override fun syncFriend(uuid: String): String? { return database.getFriendInfo(uuid)?.toJson() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt index 6026691c0..c81908ba9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt @@ -3,16 +3,9 @@ package me.rhunk.snapenhance.action import me.rhunk.snapenhance.ModContext import java.io.File -abstract class AbstractAction( - val nameKey: String -) { +abstract class AbstractAction{ lateinit var context: ModContext - /** - * called on the main thread when the mod initialize - */ - open fun init() {} - /** * called when the action is triggered */ diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt new file mode 100644 index 000000000..376786da6 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.action + +import me.rhunk.snapenhance.action.impl.CleanCache +import me.rhunk.snapenhance.action.impl.ExportChatMessages +import me.rhunk.snapenhance.action.impl.OpenMap +import kotlin.reflect.KClass + +enum class EnumAction( + val key: String, + val clazz: KClass, + val exitOnFinish: Boolean = false, + val isCritical: Boolean = false, +) { + CLEAN_CACHE("clean_snapchat_cache", CleanCache::class, exitOnFinish = true), + EXPORT_CHAT_MESSAGES("export_chat_messages", ExportChatMessages::class), + OPEN_MAP("open_map", OpenMap::class); +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt deleted file mode 100644 index 3a8e20941..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CheckForUpdates.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.features.impl.AutoUpdater - -class CheckForUpdates : AbstractAction("action.check_for_updates") { - override fun run() { - context.executeAsync { - runCatching { - val latestVersion = context.feature(AutoUpdater::class).checkForUpdates() - if (latestVersion == null) { - context.longToast(context.translation["auto_updater.no_update_available"]) - } - }.onFailure { - context.longToast(it.message ?: "Failed to check for updates") - } - } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt index dfcab689f..f8b0b449f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.action.impl import me.rhunk.snapenhance.action.AbstractAction import java.io.File -class CleanCache : AbstractAction("action.clean_cache") { +class CleanCache : AbstractAction() { companion object { private val FILES = arrayOf( "files/mbgl-offline.db", @@ -22,7 +22,7 @@ class CleanCache : AbstractAction("action.clean_cache") { } override fun run() { - FILES.forEach {fileName -> + FILES.forEach { fileName -> val fileCache = File(context.androidContext.dataDir, fileName) if (fileName.endsWith("*")) { val parent = fileCache.parentFile ?: throw IllegalStateException("Parent file is null") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt deleted file mode 100644 index b31853cf1..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ClearMessageLogger.kt +++ /dev/null @@ -1,10 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction - -class ClearMessageLogger : AbstractAction("action.clear_message_logger") { - override fun run() { - context.bridgeClient.clearMessageLogger() - context.shortToast("Message logger cleared") - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt index 990f63f00..a80c6623c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -14,10 +14,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.action.AbstractAction +import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.database.objects.FriendFeedEntry import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.util.CallbackBuilder @@ -26,7 +26,7 @@ import me.rhunk.snapenhance.util.export.MessageExporter import java.io.File @OptIn(DelicateCoroutinesApi::class) -class ExportChatMessages : AbstractAction("action.export_chat_messages") { +class ExportChatMessages : AbstractAction() { private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } @@ -55,7 +55,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { context.runOnUiThread { if (dialogLogs.size > 15) dialogLogs.removeAt(0) dialogLogs.add(message) - Logger.debug("dialog: $message") + context.log.debug("dialog: $message") currentActionDialog!!.setMessage(dialogLogs.joinToString("\n")) } } @@ -198,7 +198,6 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { } while (true) { - Logger.debug("[$conversationName] fetching $lastMessageId") val messages = fetchMessagesPaginated(conversationId, lastMessageId) if (messages.isEmpty()) break foundMessages.addAll(messages) @@ -224,7 +223,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") { it.readMessages(foundMessages) }.onFailure { logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) - Logger.error(it) + context.log.error("Failed to read messages", it) return } }.exportTo(exportType!!) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt index 4fc77d4de..bb94c6e3b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt @@ -5,7 +5,7 @@ import android.os.Bundle import me.rhunk.snapenhance.action.AbstractAction import me.rhunk.snapenhance.core.BuildConfig -class OpenMap: AbstractAction("action.open_map") { +class OpenMap: AbstractAction() { override fun run() { context.runOnUiThread { val mapActivityIntent = Intent() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt deleted file mode 100644 index b2be23f58..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/RefreshMappings.kt +++ /dev/null @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.bridge.types.BridgeFileType - -class RefreshMappings : AbstractAction("action.refresh_mappings") { - override fun run() { - context.bridgeClient.deleteFile(BridgeFileType.MAPPINGS) - context.softRestartApp() - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt similarity index 89% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt index ded51434a..a1cf662b9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.bridge +package me.rhunk.snapenhance.core.bridge import android.content.ComponentName @@ -10,11 +10,13 @@ import android.os.Handler import android.os.HandlerThread import android.os.IBinder import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.bridge.types.BridgeFileType -import me.rhunk.snapenhance.bridge.types.FileActionType +import me.rhunk.snapenhance.bridge.BridgeInterface +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.types.FileActionType import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.data.LocalePair import java.util.concurrent.CompletableFuture @@ -29,7 +31,7 @@ class BridgeClient( private lateinit var service: BridgeInterface companion object { - const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.bridge.SYNC" + const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.core.bridge.SYNC" } fun start(callback: (Boolean) -> Unit) { @@ -76,7 +78,7 @@ class BridgeClient( } override fun onNullBinding(name: ComponentName) { - xposedLog("failed to connect to bridge service") + context.log.error("BridgeClient", "failed to connect to bridge service") exitProcess(1) } @@ -84,6 +86,8 @@ class BridgeClient( exitProcess(0) } + fun broadcastLog(tag: String, level: String, message: String) = service.broadcastLog(tag, level, message) + fun createAndReadFile( fileType: BridgeFileType, defaultContent: ByteArray @@ -108,8 +112,6 @@ class BridgeClient( fun deleteMessageLoggerMessage(conversationId: String, id: Long) = service.deleteMessageLoggerMessage(conversationId, id) - fun clearMessageLogger() = service.clearMessageLogger() - fun fetchLocales(userLocale: String) = service.fetchLocales(userLocale).map { LocalePair(it.key, it.value) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/FileLoaderWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/FileLoaderWrapper.kt similarity index 91% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/FileLoaderWrapper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/FileLoaderWrapper.kt index a98e597a5..acf0c7a70 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/FileLoaderWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/FileLoaderWrapper.kt @@ -1,7 +1,7 @@ -package me.rhunk.snapenhance.bridge +package me.rhunk.snapenhance.core.bridge import android.content.Context -import me.rhunk.snapenhance.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType open class FileLoaderWrapper( private val fileType: BridgeFileType, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/BridgeFileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/BridgeFileType.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/BridgeFileType.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/BridgeFileType.kt index eca4b354e..fc120de5c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/BridgeFileType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/BridgeFileType.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.bridge.types +package me.rhunk.snapenhance.core.bridge.types import android.content.Context import java.io.File diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/FileActionType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/FileActionType.kt similarity index 62% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/FileActionType.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/FileActionType.kt index 6754198d4..49f00fe34 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/types/FileActionType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/FileActionType.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.bridge.types +package me.rhunk.snapenhance.core.bridge.types enum class FileActionType { CREATE_AND_READ, READ, WRITE, DELETE, EXISTS diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/LocaleWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt similarity index 95% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/LocaleWrapper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt index 186d87c5d..110d0f943 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/LocaleWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt @@ -1,10 +1,10 @@ -package me.rhunk.snapenhance.bridge.wrapper +package me.rhunk.snapenhance.core.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.core.bridge.BridgeClient import me.rhunk.snapenhance.data.LocalePair import java.util.Locale @@ -80,7 +80,7 @@ class LocaleWrapper { loadFromContext(context) } - operator fun get(key: String) = translationMap[key] ?: key.also { Logger.debug("Missing translation for $key") } + operator fun get(key: String) = translationMap[key] ?: key.also { Logger.directDebug("Missing translation for $key") } fun format(key: String, vararg args: Pair): String { return args.fold(get(key)) { acc, pair -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MappingsWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt similarity index 93% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MappingsWrapper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt index 938dbb799..1e7fc5089 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MappingsWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.bridge.wrapper +package me.rhunk.snapenhance.core.bridge.wrapper import android.content.Context import com.google.gson.GsonBuilder @@ -6,8 +6,8 @@ import com.google.gson.JsonElement import com.google.gson.JsonParser import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.bridge.FileLoaderWrapper -import me.rhunk.snapenhance.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.FileLoaderWrapper +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType import me.rhunk.snapmapper.Mapper import me.rhunk.snapmapper.impl.BCryptClassMapper import me.rhunk.snapmapper.impl.CallbackMapper @@ -58,11 +58,8 @@ class MappingsWrapper : FileLoaderWrapper(BridgeFileType.MAPPINGS, "{}".toByteAr runCatching { loadCached() }.onFailure { - Logger.error("Failed to load cached mappings", it) delete() } - } else { - Logger.debug("Mappings file does not exist") } } @@ -122,7 +119,7 @@ class MappingsWrapper : FileLoaderWrapper(BridgeFileType.MAPPINGS, "{}".toByteAr } write(result.toString().toByteArray()) }.also { - Logger.debug("Generated mappings in $it ms") + Logger.directDebug("Generated mappings in $it ms") } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MessageLoggerWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MessageLoggerWrapper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt index f25f4dc35..75d6395c6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/bridge/wrapper/MessageLoggerWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.bridge.wrapper +package me.rhunk.snapenhance.core.bridge.wrapper import android.content.ContentValues import android.database.sqlite.SQLiteDatabase diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt index a7a876edf..ef0c7219d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.config -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import kotlin.reflect.KProperty diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt index c4d3c32a6..dfb23accd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ModConfig.kt @@ -4,11 +4,10 @@ import android.content.Context import com.google.gson.Gson 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.FileLoaderWrapper -import me.rhunk.snapenhance.bridge.types.BridgeFileType -import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.bridge.FileLoaderWrapper +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.config.impl.RootConfig import kotlin.properties.Delegates @@ -33,7 +32,6 @@ class ModConfig { runCatching { loadConfig() }.onFailure { - Logger.error("Failed to load config", it) writeConfig() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt index 3debf9fd2..1e48f4d4d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.core.config.impl import me.rhunk.snapenhance.core.config.ConfigContainer +import me.rhunk.snapenhance.core.config.FeatureNotice class Experimental : ConfigContainer() { val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory" } @@ -9,6 +10,6 @@ class Experimental : ConfigContainer() { val appLockOnResume = boolean("app_lock_on_resume") val infiniteStoryBoost = boolean("infinite_story_boost") val meoPasscodeBypass = boolean("meo_passcode_bypass") - val unlimitedMultiSnap = boolean("unlimited_multi_snap") + val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.MAY_BAN)} val noFriendScoreDelay = boolean("no_friend_score_delay") } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index f81a72727..78c6d6b77 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -1,10 +1,14 @@ -package me.rhunk.snapenhance.database +package me.rhunk.snapenhance.core.database import android.annotation.SuppressLint import android.database.sqlite.SQLiteDatabase import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext -import me.rhunk.snapenhance.database.objects.* +import me.rhunk.snapenhance.core.database.objects.ConversationMessage +import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry +import me.rhunk.snapenhance.core.database.objects.FriendInfo +import me.rhunk.snapenhance.core.database.objects.StoryEntry +import me.rhunk.snapenhance.core.database.objects.UserConversationLink import me.rhunk.snapenhance.manager.Manager import java.io.File @@ -68,7 +72,7 @@ class DatabaseAccess(private val context: ModContext) : Manager { try { obj.write(cursor) } catch (e: Throwable) { - Logger.xposedLog(e) + context.log.error("Failed to read database object", e) } cursor.close() return obj diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseObject.kt similarity index 68% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseObject.kt index d54f2553f..def56fd5d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseObject.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.database +package me.rhunk.snapenhance.core.database import android.database.Cursor diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt index d434449fe..6e6c41ff7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt @@ -1,10 +1,10 @@ -package me.rhunk.snapenhance.database.objects +package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.core.database.DatabaseObject import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.util.ktx.getBlobOrNull import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getLong diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedEntry.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedEntry.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt index 4629d6f68..48d43835a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedEntry.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.database.objects +package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject +import me.rhunk.snapenhance.core.database.DatabaseObject import me.rhunk.snapenhance.util.ktx.getIntOrNull import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getLong diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt similarity index 96% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt index 6dd435e55..18bd832c2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.database.objects +package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject +import me.rhunk.snapenhance.core.database.DatabaseObject import me.rhunk.snapenhance.util.SerializableDataObject import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getLong diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt similarity index 86% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt index 95bdd467d..016a2ec35 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.database.objects +package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject +import me.rhunk.snapenhance.core.database.DatabaseObject import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getStringOrNull diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt similarity index 85% rename from core/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt index 28bf980e1..9590c6b13 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.database.objects +package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor -import me.rhunk.snapenhance.database.DatabaseObject +import me.rhunk.snapenhance.core.database.DatabaseObject import me.rhunk.snapenhance.util.ktx.getInteger import me.rhunk.snapenhance.util.ktx.getStringOrNull diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt similarity index 74% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt index b5dc376d0..648e27704 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadManagerClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt @@ -1,15 +1,15 @@ -package me.rhunk.snapenhance.download +package me.rhunk.snapenhance.core.download import android.content.Intent import android.os.Bundle import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.bridge.DownloadCallback -import me.rhunk.snapenhance.download.data.DashOptions -import me.rhunk.snapenhance.download.data.DownloadMediaType -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.core.download.data.DashOptions +import me.rhunk.snapenhance.core.download.data.DownloadMediaType +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadRequest +import me.rhunk.snapenhance.core.download.data.InputMedia +import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair class DownloadManagerClient ( private val context: ModContext, @@ -33,10 +33,12 @@ class DownloadManagerClient ( fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { enqueueDownloadRequest( DownloadRequest( - inputMedias = arrayOf(InputMedia( + inputMedias = arrayOf( + InputMedia( content = playlistUrl, type = DownloadMediaType.REMOTE_MEDIA - )), + ) + ), dashOptions = DashOptions(offsetTime, duration), flags = DownloadRequest.Flags.IS_DASH_PLAYLIST ) @@ -46,11 +48,13 @@ class DownloadManagerClient ( fun downloadSingleMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { enqueueDownloadRequest( DownloadRequest( - inputMedias = arrayOf(InputMedia( + inputMedias = arrayOf( + InputMedia( content = mediaData, type = mediaType, encryption = encryption - )) + ) + ) ) ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt similarity index 95% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt index 4b5fdaf83..5360191a4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt @@ -1,12 +1,12 @@ -package me.rhunk.snapenhance.download +package me.rhunk.snapenhance.core.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.DownloadObject -import me.rhunk.snapenhance.download.data.DownloadStage -import me.rhunk.snapenhance.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadObject +import me.rhunk.snapenhance.core.download.data.DownloadStage +import me.rhunk.snapenhance.core.download.data.MediaFilter import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.ktx.getIntOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMediaType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMediaType.kt similarity index 91% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMediaType.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMediaType.kt index 03c6c18d7..5cb8f9e88 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMediaType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMediaType.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data import android.net.Uri diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt similarity index 79% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMetadata.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt index e66df1664..8a342bdc3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadMetadata.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data data class DownloadMetadata( val mediaIdentifier: String?, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt similarity index 87% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt index e3eec134a..4c40b3acd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadObject.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt @@ -1,7 +1,7 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data import kotlinx.coroutines.Job -import me.rhunk.snapenhance.download.DownloadTaskManager +import me.rhunk.snapenhance.core.download.DownloadTaskManager data class DownloadObject( var downloadId: Int = 0, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt similarity index 92% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt index 4bc08bea9..3a2c06387 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data data class DashOptions(val offsetTime: Long, val duration: Long?) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadStage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadStage.kt similarity index 83% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadStage.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadStage.kt index c23c8d605..fa0e20734 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadStage.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadStage.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data enum class DownloadStage( val isFinalStage: Boolean = false, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt similarity index 93% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt index 7797363d3..30a13c4f8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data import me.rhunk.snapenhance.data.wrapper.impl.media.EncryptionWrapper import kotlin.io.encoding.Base64 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaFilter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt similarity index 89% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaFilter.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt index 1233b1ad4..edf591783 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaFilter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data enum class MediaFilter( val key: String, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/SplitMediaAssetType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/SplitMediaAssetType.kt similarity index 54% rename from core/src/main/kotlin/me/rhunk/snapenhance/download/data/SplitMediaAssetType.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/SplitMediaAssetType.kt index a1ffc26ee..c5fae162f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/download/data/SplitMediaAssetType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/SplitMediaAssetType.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.download.data +package me.rhunk.snapenhance.core.download.data enum class SplitMediaAssetType { ORIGINAL, OVERLAY diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/EventBus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/EventBus.kt index 111a8bb58..53feb5855 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/EventBus.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/EventBus.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.eventbus -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext import kotlin.reflect.KClass @@ -14,7 +13,7 @@ interface IListener { } class EventBus( - private val context: ModContext + val context: ModContext ) { private val subscribers = mutableMapOf, MutableList>>() @@ -34,7 +33,7 @@ class EventBus( runCatching { listener(event) }.onFailure { - Logger.error("Error while handling event ${event::class.simpleName}", it) + context.log.error("Error while handling event ${event::class.simpleName}", it) } } } @@ -61,7 +60,7 @@ class EventBus( runCatching { (listener as IListener).handle(event) }.onFailure { t -> - Logger.error("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}", t) + context.log.error("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}", t) } } return event diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt index f03bdc0d9..91f491163 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.features -import me.rhunk.snapenhance.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.bridge.types.BridgeFileType import java.io.BufferedReader import java.io.ByteArrayInputStream import java.io.InputStreamReader diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt index ae9b3f160..8b89a0970 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt @@ -10,7 +10,6 @@ import android.net.Uri import android.os.Build import android.os.Environment import com.google.gson.JsonParser -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams @@ -36,7 +35,7 @@ class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVI runCatching { checkForUpdates() }.onFailure { - Logger.error("Failed to check for updates: ${it.message}", it) + context.log.error("Failed to check for updates: ${it.message}", it) }.onSuccess { context.bridgeClient.setAutoUpdaterTime(currentTimeMillis) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index b96bee8a1..61c00d5bc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -6,9 +6,15 @@ import android.graphics.BitmapFactory import android.net.Uri import android.widget.ImageView import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.core.database.objects.FriendInfo +import me.rhunk.snapenhance.core.download.DownloadManagerClient +import me.rhunk.snapenhance.core.download.data.DownloadMediaType +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.InputMedia +import me.rhunk.snapenhance.core.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType +import me.rhunk.snapenhance.core.download.data.toKeyPair import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType @@ -17,14 +23,6 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistIt import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap -import me.rhunk.snapenhance.database.objects.FriendInfo -import me.rhunk.snapenhance.download.DownloadManagerClient -import me.rhunk.snapenhance.download.data.DownloadMediaType -import me.rhunk.snapenhance.download.data.DownloadMetadata -import me.rhunk.snapenhance.download.data.InputMedia -import me.rhunk.snapenhance.download.data.MediaFilter -import me.rhunk.snapenhance.download.data.SplitMediaAssetType -import me.rhunk.snapenhance.download.data.toKeyPair import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.features.impl.Messaging @@ -84,19 +82,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp callback = object: DownloadCallback.Stub() { override fun onSuccess(outputFile: String) { if (!downloadLogging.contains("success")) return - Logger.debug("onSuccess: outputFile=$outputFile") + context.log.verbose("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.contains("progress")) return - Logger.debug("onProgress: message=$message") + context.log.verbose("onProgress: message=$message") context.shortToast(message) } override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return - Logger.debug("onFailure: message=$message, throwable=$throwable") + context.log.verbose("onFailure: message=$message, throwable=$throwable") throwable?.let { context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) return @@ -402,8 +400,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp try { handleOperaMedia(mediaParamMap, mediaInfoMap, false) } catch (e: Throwable) { - xposedLog(e) - context.longToast(e.message!!) + context.log.error("Failed to handle opera media", e) + context.longToast(e.message) } } } @@ -524,11 +522,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } }.onFailure { context.shortToast(translations["failed_to_create_preview_toast"]) - xposedLog(it) + context.log.error("Failed to create preview", it) } }.onFailure { context.longToast(translations["failed_generic_toast"]) - xposedLog(it) + context.log.error("Failed to download message", it) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt index 3d7708366..555f45b73 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt @@ -3,7 +3,6 @@ package me.rhunk.snapenhance.features.impl.downloader import android.annotation.SuppressLint import android.widget.Button import android.widget.RelativeLayout -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.eventbus.events.impl.AddViewEvent import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.features.Feature @@ -45,7 +44,7 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams friendUsername!! ) }.onFailure { - Logger.error("Failed to download profile picture", it) + this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it) } } }.show() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt index 86b48c74d..a9f26e728 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.features.impl.experiments -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage @@ -17,11 +16,11 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam val fingerprintClass = android.os.Build::class.java Hooker.hook(fingerprintClass, "FINGERPRINT", HookStage.BEFORE) { hookAdapter -> hookAdapter.setResult(fingerprint) - Logger.debug("Fingerprint spoofed to $fingerprint") + context.log.verbose("Fingerprint spoofed to $fingerprint") } Hooker.hook(fingerprintClass, "deriveFingerprint", HookStage.BEFORE) { hookAdapter -> hookAdapter.setResult(fingerprint) - Logger.debug("Fingerprint spoofed to $fingerprint") + context.log.verbose("Fingerprint spoofed to $fingerprint") } } @@ -30,7 +29,7 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> if(hookAdapter.args()[1] == "android_id") { hookAdapter.setResult(androidId) - Logger.debug("Android ID spoofed to $androidId") + context.log.verbose("Android ID spoofed to $androidId") } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt index 78dc70f62..a8bddbfe2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.features.impl.privacy -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.data.NotificationType import me.rhunk.snapenhance.features.Feature @@ -28,7 +27,7 @@ class PreventMessageSending : Feature("Prevent message sending", loadParams = Fe val associatedType = NotificationType.fromContentType(contentType) ?: return@subscribe if (preventMessageSending.contains(associatedType.key)) { - Logger.debug("Preventing message sending for $associatedType") + context.log.verbose("Preventing message sending for $associatedType") event.canceled = true } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt index 7cf52d106..086776b17 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt @@ -1,7 +1,6 @@ package me.rhunk.snapenhance.features.impl.spying import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt index f95238822..54d82e313 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -3,7 +3,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 import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.impl.Message @@ -72,7 +71,7 @@ class MessageLogger : Feature("MessageLogger", context.database.getFeedEntries(PREFETCH_FEED_COUNT).forEach { friendFeedInfo -> fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList()) } - }.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") } + }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in $it") } } private fun processSnapMessage(messageInstance: Any) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt index 03505da27..66b3ed4c9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -41,7 +41,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, val callback = CallbackBuilder(callbackClass) .override("onError") { - Logger.xposedLog("Error saving message $messageId") + context.log.warn("Error saving message $messageId") }.build() runCatching { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt index 817ac10f7..1d081af99 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableVideoLengthRestriction.kt @@ -3,7 +3,6 @@ package me.rhunk.snapenhance.features.impl.tweaks import android.os.Build import android.os.FileObserver import com.google.gson.JsonParser -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams @@ -32,7 +31,7 @@ class DisableVideoLengthRestriction : Feature("DisableVideoLengthRestriction", l val fileContent = JsonParser.parseReader(file.reader()).asJsonObject if (fileContent["timerOrDuration"].asLong < 0) file.delete() }.onFailure { - Logger.error("Failed to read story metadata file", it) + context.log.error("Failed to read story metadata file", it) } } }) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt index 4a5ec5619..519cadef8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt @@ -1,7 +1,6 @@ package me.rhunk.snapenhance.features.impl.tweaks import android.app.AlertDialog -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage @@ -15,7 +14,7 @@ class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = Fe 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") + context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null") param.setResult(null) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt index 3ebaaa2ba..be27dd6da 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -13,12 +13,12 @@ import android.os.UserHandle import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent 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 @@ -297,7 +297,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN fetchMessagesResult(conversationId, messageList) } .override("onError") { - Logger.xposedLog("Failed to fetch message ${it.arg(0) as Any}") + context.log.error("Failed to fetch message ${it.arg(0) as Any}") }.build() fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instanceNonNull(), callback) @@ -323,7 +323,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val intent = param.argNullable(0) ?: return@hook val messageType = intent.getStringExtra("type") ?: return@hook - Logger.xposedLog("received message type: $messageType") + context.log.debug("received message type: $messageType") if (states.contains(messageType.replaceFirst("mischief_", ""))) { param.setResult(null) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt index 04b0ec196..a7098ef4c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt +++ b/core/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.types.BridgeFileType +import me.rhunk.snapenhance.core.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/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt index a00c74193..33dd7c6f2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt @@ -1,38 +1,37 @@ package me.rhunk.snapenhance.manager.impl +import android.content.Intent import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.action.impl.CheckForUpdates -import me.rhunk.snapenhance.action.impl.CleanCache -import me.rhunk.snapenhance.action.impl.ClearMessageLogger -import me.rhunk.snapenhance.action.impl.ExportChatMessages -import me.rhunk.snapenhance.action.impl.OpenMap -import me.rhunk.snapenhance.action.impl.RefreshMappings -import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.action.EnumAction import me.rhunk.snapenhance.manager.Manager -import kotlin.reflect.KClass class ActionManager( - private val context: ModContext, + private val modContext: ModContext, ) : Manager { - private val actions = mutableMapOf() - fun getActions() = actions.values.toList() - private fun load(clazz: KClass) { - val action = clazz.java.newInstance() - action.context = context - actions[action.nameKey] = action + companion object { + const val ACTION_PARAMETER = "se_action" } + private val actions = mutableMapOf() + override fun init() { - load(CleanCache::class) - load(ExportChatMessages::class) - load(OpenMap::class) - load(CheckForUpdates::class) - if(BuildConfig.DEBUG) { - load(ClearMessageLogger::class) - load(RefreshMappings::class) + EnumAction.values().forEach { enumAction -> + actions[enumAction.key] = enumAction.clazz.java.getConstructor().newInstance().apply { + this.context = modContext + } } + } + fun onNewIntent(intent: Intent?) { + val action = intent?.getStringExtra(ACTION_PARAMETER) ?: return + execute(EnumAction.values().find { it.key == action } ?: return) + intent.removeExtra(ACTION_PARAMETER) + } - actions.values.forEach(AbstractAction::init) + private fun execute(action: EnumAction) { + actions[action.key]?.run() + if (action.exitOnFinish) { + modContext.forceCloseApp() + } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt index 665377610..918d89d41 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -11,11 +11,10 @@ import android.view.View import android.widget.Button import android.widget.CompoundButton import android.widget.Switch -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.database.objects.ConversationMessage +import me.rhunk.snapenhance.core.database.objects.FriendInfo +import me.rhunk.snapenhance.core.database.objects.UserConversationLink import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.database.objects.ConversationMessage -import me.rhunk.snapenhance.database.objects.FriendInfo -import me.rhunk.snapenhance.database.objects.UserConversationLink import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader @@ -57,7 +56,7 @@ class FriendFeedInfoMenu : AbstractMenu() { ) } } catch (e: Throwable) { - Logger.xposedLog(e) + context.log.error("Error loading bitmoji selfie", e) } val finalIcon = icon context.runOnUiThread { @@ -243,7 +242,6 @@ class FriendFeedInfoMenu : AbstractMenu() { rules.forEach { ruleFeature -> if (!friendFeedMenuOptions.contains(ruleFeature.ruleType.key)) return@forEach - Logger.debug("${ruleFeature.ruleType.key} ${ruleFeature.getRuleState()}") val ruleState = ruleFeature.getRuleState() ?: return@forEach createToggleFeature(viewConsumer, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt index 064383824..e0cc24827 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt @@ -8,7 +8,6 @@ import android.widget.Button import android.widget.LinearLayout import android.widget.ScrollView import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.ui.ViewAppearanceHelper.applyTheme import me.rhunk.snapenhance.ui.menu.AbstractMenu @@ -76,7 +75,7 @@ class OperaContextActionMenu : AbstractMenu() { linearLayout.addView(button) (childView as ViewGroup).addView(linearLayout, 0) } catch (e: Throwable) { - Logger.xposedLog(e) + context.log.error("Error while injecting OperaContextActionMenu", e) } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt index 68eadf9ca..34c5e3217 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt @@ -2,15 +2,13 @@ package me.rhunk.snapenhance.ui.menu.impl import android.annotation.SuppressLint import android.view.View -import android.widget.Button -import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.AbstractMenu class SettingsMenu : AbstractMenu() { //TODO: quick settings @SuppressLint("SetTextI18n") fun inject(viewModel: View, addView: (View) -> Unit) { - val actions = context.actionManager.getActions().map { + /*val actions = context.actionManager.getActions().map { Pair(it) { val button = Button(viewModel.context) button.text = context.translation[it.nameKey] @@ -25,6 +23,6 @@ class SettingsMenu : AbstractMenu() { actions.forEach { addView(it.second()) - } + }*/ } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt index 50b4434a2..c97680891 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt @@ -23,7 +23,7 @@ object SQLiteDatabaseHelper { if (newColumns.isEmpty()) return@forEach - Logger.log("Schema for table $tableName has changed") + Logger.directDebug("Schema for table $tableName has changed") sqLiteDatabase.execSQL("DROP TABLE $tableName") sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt index 02e663ed1..e50496a22 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt @@ -37,7 +37,7 @@ class HttpServer( } coroutineScope.launch(Dispatchers.IO) { - Logger.debug("starting http server on port $port") + Logger.directDebug("starting http server on port $port") serverSocket = ServerSocket(port) callback(this@HttpServer) while (!serverSocket!!.isClosed) { @@ -48,21 +48,21 @@ class HttpServer( handleRequest(socket) timeoutJob = launch { delay(timeout.toLong()) - Logger.debug("http server closed due to timeout") + Logger.directDebug("http server closed due to timeout") runCatching { socketJob?.cancel() socket.close() serverSocket?.close() }.onFailure { - Logger.error(it) + Logger.directError("failed to close socket", it) } } } } catch (e: SocketException) { - Logger.debug("http server timed out") + Logger.directDebug("http server timed out") break; } catch (e: Throwable) { - Logger.error("failed to handle request", e) + Logger.directError("failed to handle request", e) } } }.also { socketJob = it } @@ -90,13 +90,13 @@ class HttpServer( outputStream.close() socket.close() }.onFailure { - Logger.error("failed to close socket", it) + Logger.directError("failed to close socket", it) } } val parse = StringTokenizer(line) val method = parse.nextToken().uppercase(Locale.getDefault()) var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) - Logger.debug("[http-server:${port}] $method $fileRequested") + Logger.directDebug("[http-server:${port}] $method $fileRequested") if (method != "GET") { with(writer) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt index 718cef505..839093d07 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt @@ -44,7 +44,7 @@ object RemoteMediaResolver { okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) { - Logger.log("Unexpected code $response") + Logger.directDebug("Unexpected code $response") return null } return ByteArrayInputStream(response.body.bytes()) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt index ad6f5c0a4..ad3647029 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt @@ -9,16 +9,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry +import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType 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.database.objects.FriendFeedEntry -import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper @@ -138,7 +137,7 @@ class MessageExporter( } }.onFailure { printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}") - Logger.error("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}", it) + context.log.error("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}", it) } } } @@ -219,7 +218,7 @@ class MessageExporter( } }.onFailure { printLog("failed to read template from apk") - Logger.error("failed to read template from apk", it) + context.log.error("failed to read template from apk", it) } output.write("".toByteArray()) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt index e8ea611ac..df4a79738 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -4,9 +4,9 @@ import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegSession import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType 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 From c791fbbd005f1ec1acece35a6434ae3695d58290 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 31 Aug 2023 03:24:33 +0200 Subject: [PATCH 005/274] fix(ui): social section - fix logger line separator --- .../kotlin/me/rhunk/snapenhance/LogManager.kt | 34 ++++++--- .../ui/manager/sections/home/HomeSection.kt | 12 ++-- .../manager/sections/home/HomeSubSection.kt | 72 ++++++++++++++++++- .../manager/sections/social/ScopeContent.kt | 24 +++++-- .../manager/sections/social/SocialSection.kt | 15 ++-- .../kotlin/me/rhunk/snapenhance/Logger.kt | 45 +++++++++++- 6 files changed, 175 insertions(+), 27 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index caabcd530..5499eed49 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance import android.content.SharedPreferences import android.util.Log +import com.google.gson.GsonBuilder import java.io.File import java.io.OutputStream import java.io.RandomAccessFile @@ -42,6 +43,21 @@ class LogReader( private var startLineIndexes = mutableListOf() var lineCount = queryLineCount() + private fun readLogLine(): LogLine? { + val lines = mutableListOf() + while (true) { + val lastPointer = randomAccessFile.filePointer + val line = randomAccessFile.readLine() ?: return null + if (lines.size > 0 && line.startsWith("|")) { + randomAccessFile.seek(lastPointer) + break + } + lines.add(line) + } + val line = lines.joinToString("\n").replaceFirst("|", "") + return LogLine.fromString(line) + } + fun incrementLineCount() { randomAccessFile.seek(randomAccessFile.length()) startLineIndexes.add(randomAccessFile.filePointer) @@ -54,7 +70,7 @@ class LogReader( var lastIndex: Long while (true) { lastIndex = randomAccessFile.filePointer - randomAccessFile.readLine() ?: break + readLogLine() ?: break startLineIndexes.add(lastIndex) lines++ } @@ -64,7 +80,7 @@ class LogReader( private fun getLine(index: Int): String? { if (index <= 0 || index > lineCount) return null randomAccessFile.seek(startLineIndexes[index]) - return randomAccessFile.readLine() + return readLogLine()?.toString() } fun getLogLine(index: Int): LogLine? { @@ -74,7 +90,7 @@ class LogReader( class LogManager( - remoteSideContext: RemoteSideContext + private val remoteSideContext: RemoteSideContext ) { companion object { private const val TAG = "SnapEnhanceManager" @@ -118,12 +134,10 @@ class LogManager( } fun clearLogs() { - logFile.delete() + logFolder.listFiles()?.forEach { it.delete() } newLogFile() } - fun getLogFile() = logFile - fun exportLogsToZip(outputStream: OutputStream) { val zipOutputStream = ZipOutputStream(outputStream) //add logFolder to zip @@ -136,8 +150,10 @@ class LogManager( } //add device info to zip - zipOutputStream.putNextEntry(ZipEntry("device_info.txt")) - + zipOutputStream.putNextEntry(ZipEntry("device_info.json")) + val gson = GsonBuilder().setPrettyPrinting().create() + zipOutputStream.write(gson.toJson(remoteSideContext.installationSummary).toByteArray()) + zipOutputStream.closeEntry() zipOutputStream.close() } @@ -178,7 +194,7 @@ class LogManager( fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { runCatching { val line = LogLine(logLevel, getCurrentDateTime(), tag, message.toString()) - logFile.appendText("$line\n", Charsets.UTF_8) + logFile.appendText("|$line\n", Charsets.UTF_8) lineAddListener(line) Log.println(logLevel.priority, tag, message.toString()) }.onFailure { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 6da9610a1..0c384f176 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -237,11 +237,15 @@ class HomeSection : Section() { }) DropdownMenuItem(onClick = { - val logFile = context.log.getLogFile() - activityLauncherHelper.saveFile(logFile.name, "text/plain") { uri -> + activityLauncherHelper.saveFile("snapenhance-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri -> context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { - logFile.inputStream().copyTo(it) - context.longToast("Saved logs to $uri") + runCatching { + context.log.exportLogsToZip(it) + context.longToast("Saved logs to $uri") + }.onFailure { + context.longToast("Failed to save logs to $uri!") + context.log.error("Failed to save logs to $uri!", it) + } } } showDropDown = false diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt index 0e371e9fd..c248f8474 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -3,10 +3,13 @@ package me.rhunk.snapenhance.ui.manager.sections.home import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,9 +22,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -36,13 +42,19 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.LogChannels +import me.rhunk.snapenhance.LogLevel import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.action.EnumAction @@ -106,6 +118,7 @@ class HomeSubSection( @Composable fun LogsSection() { val coroutineScope = rememberCoroutineScope() + val clipboardManager = LocalClipboardManager.current var lineCount by remember { mutableIntStateOf(0) } var logReader by remember { mutableStateOf(null) } logListState = remember { LazyListState(0) } @@ -120,12 +133,65 @@ class HomeSubSection( ) { items(lineCount) { index -> val line = logReader?.getLogLine(index) ?: return@items + var expand by remember { mutableStateOf(false) } Box(modifier = Modifier .fillMaxWidth() .background( if (index % 2 == 0) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant - )) { - Text(text = line.message, modifier = Modifier.padding(9.dp), fontSize = 10.sp) + ) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + coroutineScope.launch { + clipboardManager.setText(AnnotatedString(line.message)) + } + }, + onTap = { + expand = !expand + } + ) + }) { + + Row( + modifier = Modifier + .horizontalScroll(ScrollState(0)) + .padding(4.dp) + .defaultMinSize(minHeight = 30.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!expand) { + Icon( + imageVector = when (line.logLevel) { + LogLevel.DEBUG -> Icons.Outlined.BugReport + LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report + LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info + LogLevel.WARN -> Icons.Outlined.Warning + }, + contentDescription = null, + ) + + Text( + text = LogChannels.fromChannel(line.tag)?.shortName ?: line.tag, + modifier = Modifier.padding(start = 4.dp), + fontWeight = FontWeight.Light, + fontSize = 10.sp, + ) + + Text( + text = line.dateTime, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + fontSize = 10.sp + ) + } + + Text( + text = line.message.trimIndent(), + fontSize = 10.sp, + maxLines = if (expand) Int.MAX_VALUE else 6, + overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis, + softWrap = !expand, + ) + } } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt index b52f451cb..6ca75dd03 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -241,10 +241,26 @@ class ScopeContent( return } - Column { - Text(text = group.name, maxLines = 1) - Text(text = "participantsCount: ${group.participantsCount}", maxLines = 1) - Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = group.name, + maxLines = 1, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = "Participants: ${group.participantsCount}", + maxLines = 1, + fontSize = 12.sp, + fontWeight = FontWeight.Light + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index 77ab4d87f..ae5655c6a 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.ui.manager.sections.social import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -147,8 +149,7 @@ class SocialSection : Section() { if (listSize == 0) { item { - //TODO: i18n - Text(text = "No ${scope.key.lowercase()}s found") + Text(text = "(empty)", modifier = Modifier.fillMaxWidth().padding(10.dp), textAlign = TextAlign.Center) } } @@ -172,9 +173,13 @@ class SocialSection : Section() { when (scope) { SocialScope.GROUP -> { val group = groupList[index] - Column { - Text(text = group.name, maxLines = 1) - Text(text = "participantsCount: ${group.participantsCount}", maxLines = 1) + Column( + modifier = Modifier + .padding(10.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center + ) { + Text(text = group.name, maxLines = 1, fontWeight = FontWeight.Bold) } } SocialScope.FRIEND -> { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt index f78dd213f..51cd4838f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt @@ -1,8 +1,11 @@ package me.rhunk.snapenhance +import android.annotation.SuppressLint import android.util.Log import de.robv.android.xposed.XposedBridge import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.hook enum class LogLevel( val letter: String, @@ -24,10 +27,28 @@ enum class LogLevel( fun fromShortName(shortName: String): LogLevel? { return values().find { it.shortName == shortName } } + + fun fromPriority(priority: Int): LogLevel? { + return values().find { it.priority == priority } + } + } +} + +enum class LogChannels(val channel: String, val shortName: String) { + CORE("SnapEnhanceCore", "core"), + NATIVE("SnapEnhanceNative", "native"), + MANAGER("SnapEnhanceManager", "manager"), + XPOSED("LSPosed-Bridge", "xposed"); + + companion object { + fun fromChannel(channel: String): LogChannels? { + return values().find { it.channel == channel } + } } } +@SuppressLint("PrivateApi") class Logger( private val bridgeClient: BridgeClient ) { @@ -55,11 +76,31 @@ class Logger( } } + private var invokeOriginalPrintLog: (Int, String, String) -> Unit + + init { + val printLnMethod = Log::class.java.getDeclaredMethod("println", Int::class.java, String::class.java, String::class.java) + printLnMethod.hook(HookStage.BEFORE) { param -> + val priority = param.arg(0) as Int + val tag = param.arg(1) as String + val message = param.arg(2) as String + internalLog(tag, LogLevel.fromPriority(priority) ?: LogLevel.INFO, message) + } + + invokeOriginalPrintLog = { priority, tag, message -> + XposedBridge.invokeOriginalMethod( + printLnMethod, + null, + arrayOf(priority, tag, message) + ) + } + } + private fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { runCatching { bridgeClient.broadcastLog(tag, logLevel.shortName, message.toString()) }.onFailure { - Log.println(logLevel.priority, tag, message.toString()) + invokeOriginalPrintLog(logLevel.priority, tag, message.toString()) } } @@ -69,7 +110,7 @@ class Logger( fun error(message: Any?, throwable: Throwable, tag: String = TAG) { internalLog(tag, LogLevel.ERROR, message) - internalLog(tag, LogLevel.ERROR, throwable) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) } fun info(message: Any?, tag: String = TAG) = internalLog(tag, LogLevel.INFO, message) From dded6acff08dc925213bb7ab80a172d52da462e6 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:42:24 +0200 Subject: [PATCH 006/274] feat: add friend source spoof --- core/src/main/assets/lang/en_US.json | 11 ++++ .../core/bridge/wrapper/MappingsWrapper.kt | 4 +- .../core/config/impl/Experimental.kt | 7 +++ .../core/database/DatabaseAccess.kt | 3 + .../me/rhunk/snapenhance/features/Feature.kt | 2 +- .../impl/experiments/AddFriendSourceSpoof.kt | 55 +++++++++++++++++++ .../manager/impl/FeatureManager.kt | 6 +- .../impl/FriendRelationshipChangerMapper.kt | 27 +++++++++ .../snapenhance/mapper/tests/TestMappings.kt | 1 + 9 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AddFriendSourceSpoof.kt create mode 100644 mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendRelationshipChangerMapper.kt diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index a1062f511..cb351cc3c 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -366,6 +366,10 @@ "no_friend_score_delay": { "name": "No Friend Score Delay", "description": "Removes the delay when viewing a friends score" + }, + "add_friend_source_spoof": { + "name": "Add Friend Source Spoof", + "description": "Spoofs the source of a friend request" } } } @@ -461,6 +465,13 @@ "ngs_community_icon_container": "Community / Stories", "ngs_spotlight_icon_container": "Spotlight", "ngs_search_icon_container": "Search" + }, + "add_friend_source_spoof": { + "added_by_username": "By Username", + "added_by_mention": "By Mention", + "added_by_group_chat": "By Group Chat", + "added_by_qr_code": "By QR Code", + "added_by_community": "By Community" } } }, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt index 1e7fc5089..02a5d957f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt @@ -14,6 +14,7 @@ import me.rhunk.snapmapper.impl.CallbackMapper import me.rhunk.snapmapper.impl.CompositeConfigurationProviderMapper import me.rhunk.snapmapper.impl.DefaultMediaItemMapper import me.rhunk.snapmapper.impl.EnumMapper +import me.rhunk.snapmapper.impl.FriendRelationshipChangerMapper import me.rhunk.snapmapper.impl.FriendsFeedEventDispatcherMapper import me.rhunk.snapmapper.impl.MediaQualityLevelProviderMapper import me.rhunk.snapmapper.impl.OperaPageViewControllerMapper @@ -41,7 +42,8 @@ class MappingsWrapper : FileLoaderWrapper(BridgeFileType.MAPPINGS, "{}".toByteAr StoryBoostStateMapper::class, FriendsFeedEventDispatcherMapper::class, CompositeConfigurationProviderMapper::class, - ScoreUpdateMapper::class + ScoreUpdateMapper::class, + FriendRelationshipChangerMapper::class, ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt index 1e48f4d4d..01360013a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt @@ -12,4 +12,11 @@ class Experimental : ConfigContainer() { val meoPasscodeBypass = boolean("meo_passcode_bypass") val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.MAY_BAN)} val noFriendScoreDelay = boolean("no_friend_score_delay") + val addFriendSourceSpoof = unique("add_friend_source_spoof", + "added_by_username", + "added_by_mention", + "added_by_group_chat", + "added_by_qr_code", + "added_by_community", + ) { addNotices(FeatureNotice.MAY_BAN) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 78c6d6b77..eb095f9e2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -49,6 +49,9 @@ class DatabaseAccess(private val context: ModContext) : Manager { query: (SQLiteDatabase) -> T? ): T? { synchronized(databaseLock) { + if (!database.isOpen) { + return null + } return runCatching { query(database) }.onFailure { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt index df9fa6e40..49e1a6090 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.features import me.rhunk.snapenhance.ModContext abstract class Feature( - val nameKey: String, + val featureKey: String, val loadParams: Int = FeatureLoadParams.INIT_SYNC ) { lateinit var context: ModContext diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AddFriendSourceSpoof.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AddFriendSourceSpoof.kt new file mode 100644 index 000000000..6e7397322 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AddFriendSourceSpoof.kt @@ -0,0 +1,55 @@ +package me.rhunk.snapenhance.features.impl.experiments + +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.hook + +class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") + + findClass(friendRelationshipChangerMapping["class"].toString()) + .hook(friendRelationshipChangerMapping["addFriendMethod"].toString(), HookStage.BEFORE) { param -> + val spoofedSource = context.config.experimental.addFriendSourceSpoof.getNullable() ?: return@hook + + context.log.verbose("addFriendMethod: ${param.args().toList()}", featureKey) + + fun setEnum(index: Int, value: String) { + val enumData = param.arg(index) + enumData::class.java.enumConstants.first { it.toString() == value }.let { + param.setArg(index, it) + } + } + + when (spoofedSource) { + "added_by_group_chat" -> { + setEnum(1, "PROFILE") + setEnum(2, "GROUP_PROFILE") + setEnum(3, "ADDED_BY_GROUP_CHAT") + } + "added_by_username" -> { + setEnum(1, "SEARCH") + setEnum(2, "SEARCH") + setEnum(3, "ADDED_BY_USERNAME") + } + "added_by_qr_code" -> { + setEnum(1, "PROFILE") + setEnum(2, "PROFILE") + setEnum(3, "ADDED_BY_QR_CODE") + } + "added_by_mention" -> { + setEnum(1, "CONTEXT_CARDS") + setEnum(2, "CONTEXT_CARD") + setEnum(3, "ADDED_BY_MENTION") + } + "added_by_community" -> { + setEnum(1, "PROFILE") + setEnum(2, "PROFILE") + setEnum(3, "ADDED_BY_COMMUNITY") + } + else -> return@hook + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index cf463bb78..739629583 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -9,6 +9,7 @@ import me.rhunk.snapenhance.features.impl.ConfigurationOverride import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.downloader.ProfilePictureDownloader +import me.rhunk.snapenhance.features.impl.experiments.AddFriendSourceSpoof import me.rhunk.snapenhance.features.impl.experiments.AmoledDarkMode import me.rhunk.snapenhance.features.impl.experiments.AppPasscode import me.rhunk.snapenhance.features.impl.experiments.DeviceSpooferHook @@ -93,6 +94,7 @@ class FeatureManager(private val context: ModContext) : Manager { register(GooglePlayServicesDialogs::class) register(NoFriendScoreDelay::class) register(ProfilePictureDownloader::class) + register(AddFriendSourceSpoof::class) initializeFeatures() } @@ -103,8 +105,8 @@ class FeatureManager(private val context: ModContext) : Manager { runCatching { action(feature) }.onFailure { - Logger.xposedLog("Failed to init feature ${feature.nameKey}", it) - context.longToast("Failed to init feature ${feature.nameKey}") + context.log.error("Failed to init feature ${feature.featureKey}", it) + context.longToast("Failed to load feature ${feature.featureKey}! Check logcat for more details.") } } if (!isAsync) { diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendRelationshipChangerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendRelationshipChangerMapper.kt new file mode 100644 index 000000000..d76a53db0 --- /dev/null +++ b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/FriendRelationshipChangerMapper.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 +import me.rhunk.snapmapper.ext.isEnum + +class FriendRelationshipChangerMapper : AbstractClassMapper() { + override fun run(context: MapperContext) { + for (classDef in context.classes) { + classDef.methods.firstOrNull { it.name == "" }?.implementation?.findConstString("FriendRelationshipChangerImpl")?.takeIf { it } ?: continue + val addFriendMethod = classDef.methods.first { + it.parameterTypes.size > 4 && + context.getClass(it.parameterTypes[1])?.isEnum() == true && + context.getClass(it.parameterTypes[2])?.isEnum() == true && + context.getClass(it.parameterTypes[3])?.isEnum() == true && + it.parameters[4].type == "Ljava/lang/String;" + } + + context.addMapping("FriendRelationshipChanger", + "class" to classDef.getClassName(), + "addFriendMethod" to addFriendMethod.name + ) + } + } +} \ 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 index 5da213fd3..c4a562a5f 100644 --- a/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt +++ b/mapper/src/test/kotlin/me/rhunk/snapenhance/mapper/tests/TestMappings.kt @@ -24,6 +24,7 @@ class TestMappings { FriendsFeedEventDispatcherMapper::class, CompositeConfigurationProviderMapper::class, ScoreUpdateMapper::class, + FriendRelationshipChangerMapper::class, ) val gson = GsonBuilder().setPrettyPrinting().create() From ea6260463c8339800f252c7744f6bccc19c8ab55 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:42:45 +0200 Subject: [PATCH 007/274] fix(logger): invalid log format --- .../kotlin/me/rhunk/snapenhance/LogManager.kt | 22 +++++++++++++------ .../manager/sections/home/HomeSubSection.kt | 10 ++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index 5499eed49..e99616ed3 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -44,18 +44,26 @@ class LogReader( var lineCount = queryLineCount() private fun readLogLine(): LogLine? { - val lines = mutableListOf() + val lines = StringBuilder() + val lastPointer = randomAccessFile.filePointer + var lastChar: Int = -1 + var bufferLength = 0 while (true) { - val lastPointer = randomAccessFile.filePointer - val line = randomAccessFile.readLine() ?: return null - if (lines.size > 0 && line.startsWith("|")) { + val char = randomAccessFile.read() + if (char == -1) { randomAccessFile.seek(lastPointer) + return null + } + if ((char == '|'.code && lastChar == '\n'.code) || bufferLength > 4096) { break } - lines.add(line) + lines.append(char.toChar()) + bufferLength++ + lastChar = char } - val line = lines.joinToString("\n").replaceFirst("|", "") - return LogLine.fromString(line) + + return LogLine.fromString(lines.trimEnd().toString()) + ?: LogLine(LogLevel.ERROR, "1970-01-01 00:00:00", "LogReader", "Failed to parse log line: $lines") } fun incrementLineCount() { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt index c248f8474..9c1448ee9 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -128,7 +128,8 @@ class HomeSubSection( .fillMaxSize() ) { LazyColumn( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.background(MaterialTheme.colorScheme.surface ) + .horizontalScroll(ScrollState(0)), state = logListState ) { items(lineCount) { index -> @@ -136,9 +137,6 @@ class HomeSubSection( var expand by remember { mutableStateOf(false) } Box(modifier = Modifier .fillMaxWidth() - .background( - if (index % 2 == 0) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant - ) .pointerInput(Unit) { detectTapGestures( onLongPress = { @@ -154,8 +152,8 @@ class HomeSubSection( Row( modifier = Modifier - .horizontalScroll(ScrollState(0)) .padding(4.dp) + .fillMaxWidth() .defaultMinSize(minHeight = 30.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -231,7 +229,7 @@ class HomeSubSection( FilledIconButton(onClick = { coroutineScope.launch { - logListState.scrollToItem(logListState.layoutInfo.totalItemsCount - 1) + logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch) } }) { Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null) From 5776d4411103691e0f09a2e902cb6e0591a8ccb5 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 11:50:42 +0200 Subject: [PATCH 008/274] fix(media_downloader): story voice note reply - refactor media author and download source - optimize download section --- .../kotlin/me/rhunk/snapenhance/LogManager.kt | 2 +- .../me/rhunk/snapenhance/RemoteSideContext.kt | 2 +- .../snapenhance/download}/DownloadObject.kt | 10 +- .../snapenhance/download/DownloadProcessor.kt | 8 +- .../download/DownloadTaskManager.kt | 41 ++++---- .../sections/downloads/DownloadsSection.kt | 67 ++++++++----- .../snapenhance/ui/util/ComposeImageHelper.kt | 2 + core/src/main/assets/lang/en_US.json | 7 +- .../core/config/impl/DownloaderConfig.kt | 7 +- .../core/download/data/DownloadMetadata.kt | 4 +- .../core/download/data/MediaDownloadSource.kt | 28 ++++++ .../core/download/data/MediaFilter.kt | 18 ---- .../impl/downloader/MediaDownloader.kt | 96 +++++++++---------- .../snapenhance/util/snap/EncryptionHelper.kt | 6 +- .../util/snap/MediaDownloaderHelper.kt | 11 ++- 15 files changed, 170 insertions(+), 139 deletions(-) rename {core/src/main/kotlin/me/rhunk/snapenhance/core/download/data => app/src/main/kotlin/me/rhunk/snapenhance/download}/DownloadObject.kt (72%) rename {core/src/main/kotlin/me/rhunk/snapenhance/core => app/src/main/kotlin/me/rhunk/snapenhance}/download/DownloadTaskManager.kt (79%) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index e99616ed3..1649e2570 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -180,7 +180,7 @@ class LogManager( fun error(message: Any?, throwable: Throwable, tag: String = TAG) { internalLog(tag, LogLevel.ERROR, message) - internalLog(tag, LogLevel.ERROR, throwable) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) } fun info(message: Any?, tag: String = TAG) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 75c50c4ca..4a1c0dbdf 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -20,7 +20,7 @@ import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig -import me.rhunk.snapenhance.core.download.DownloadTaskManager +import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.ui.manager.MainActivity diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt similarity index 72% rename from core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt index 4c40b3acd..73fdc8ad9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadObject.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt @@ -1,17 +1,19 @@ -package me.rhunk.snapenhance.core.download.data +package me.rhunk.snapenhance.download import kotlinx.coroutines.Job -import me.rhunk.snapenhance.core.download.DownloadTaskManager +import me.rhunk.snapenhance.core.download.data.DownloadMetadata +import me.rhunk.snapenhance.core.download.data.DownloadStage data class DownloadObject( var downloadId: Int = 0, var outputFile: String? = null, val metadata : DownloadMetadata ) { - lateinit var downloadTaskManager: DownloadTaskManager var job: Job? = null var changeListener = { _: DownloadStage, _: DownloadStage -> } + lateinit var updateTaskCallback: (DownloadObject) -> Unit + private var _stage: DownloadStage = DownloadStage.PENDING var downloadStage: DownloadStage get() = synchronized(this) { @@ -20,7 +22,7 @@ data class DownloadObject( set(value) = synchronized(this) { changeListener(_stage, value) _stage = value - downloadTaskManager.updateTask(this) + updateTaskCallback(this) } fun isJobActive() = job?.isActive == true diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 7aeec42eb..b92b88ea7 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -16,14 +16,12 @@ 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.RemoteSideContext import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMetadata -import me.rhunk.snapenhance.core.download.data.DownloadObject import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia @@ -320,7 +318,11 @@ class DownloadProcessor ( val downloadObjectObject = DownloadObject( metadata = downloadMetadata - ).apply { downloadTaskManager = remoteSideContext.downloadTaskManager } + ).apply { + updateTaskCallback = { + remoteSideContext.downloadTaskManager.updateTask(it) + } + } downloadObjectObject.also { remoteSideContext.downloadTaskManager.addTask(it) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt similarity index 79% rename from core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt rename to app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt index 5360191a4..916fc83d7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadTaskManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -1,12 +1,11 @@ -package me.rhunk.snapenhance.core.download +package me.rhunk.snapenhance.download import android.annotation.SuppressLint import android.content.Context import android.database.sqlite.SQLiteDatabase import me.rhunk.snapenhance.core.download.data.DownloadMetadata -import me.rhunk.snapenhance.core.download.data.DownloadObject import me.rhunk.snapenhance.core.download.data.DownloadStage -import me.rhunk.snapenhance.core.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.ktx.getIntOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull @@ -26,8 +25,8 @@ class DownloadTaskManager { "hash VARCHAR UNIQUE", "outputPath TEXT", "outputFile TEXT", - "mediaDisplayType TEXT", - "mediaDisplaySource TEXT", + "mediaAuthor TEXT", + "downloadSource TEXT", "iconUrl TEXT", "downloadStage TEXT" ) @@ -36,13 +35,13 @@ class DownloadTaskManager { } fun addTask(task: DownloadObject): Int { - taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", + taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, downloadSource, mediaAuthor, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", arrayOf( task.metadata.mediaIdentifier, task.metadata.outputPath, task.outputFile, - task.metadata.mediaDisplayType, - task.metadata.mediaDisplaySource, + task.metadata.downloadSource, + task.metadata.mediaAuthor, task.metadata.iconUrl, task.downloadStage.name ) @@ -56,13 +55,13 @@ class DownloadTaskManager { } fun updateTask(task: DownloadObject) { - taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", + taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, downloadSource = ?, mediaAuthor = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", arrayOf( task.metadata.mediaIdentifier, task.metadata.outputPath, task.outputFile, - task.metadata.mediaDisplayType, - task.metadata.mediaDisplaySource, + task.metadata.downloadSource, + task.metadata.mediaAuthor, task.metadata.iconUrl, task.downloadStage.name, task.downloadId @@ -113,11 +112,11 @@ class DownloadTaskManager { removeTask(task.downloadId) } - fun queryFirstTasks(filter: MediaFilter): Map { - val isPendingFilter = filter == MediaFilter.PENDING + fun queryFirstTasks(filter: MediaDownloadSource): Map { + val isPendingFilter = filter == MediaDownloadSource.PENDING val tasks = mutableMapOf() - tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) }) + tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.downloadSource) }) if (isPendingFilter) { return tasks.toSortedMap(reverseOrder()) } @@ -132,16 +131,16 @@ class DownloadTaskManager { } @SuppressLint("Range") - fun queryTasks(from: Int, amount: Int = 30, filter: MediaFilter = MediaFilter.NONE): Map { - if (filter == MediaFilter.PENDING) { + fun queryTasks(from: Int, amount: Int = 30, filter: MediaDownloadSource = MediaDownloadSource.NONE): Map { + if (filter == MediaDownloadSource.PENDING) { return emptyMap() } val cursor = taskDatabase.rawQuery( - "SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?", + "SELECT * FROM tasks WHERE id < ? AND downloadSource LIKE ? ORDER BY id DESC LIMIT ?", arrayOf( from.toString(), - if (filter.shouldIgnoreFilter) "%" else "%${filter.key}", + if (filter.ignoreFilter) "%" else "%${filter.key}", amount.toString() ) ) @@ -155,12 +154,12 @@ class DownloadTaskManager { metadata = DownloadMetadata( outputPath = cursor.getStringOrNull("outputPath")!!, mediaIdentifier = cursor.getStringOrNull("hash"), - mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"), - mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"), + downloadSource = cursor.getStringOrNull("downloadSource") ?: MediaDownloadSource.NONE.key, + mediaAuthor = cursor.getStringOrNull("mediaAuthor"), iconUrl = cursor.getStringOrNull("iconUrl") ) ).apply { - downloadTaskManager = this@DownloadTaskManager + updateTaskCallback = { updateTask(it) } downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!) //if downloadStage is not saved, it means the app was killed before the download was finished if (downloadStage != DownloadStage.SAVED) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt index 2cd20aadb..2e4cb3d85 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -49,27 +50,42 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.core.download.data.DownloadObject -import me.rhunk.snapenhance.core.download.data.MediaFilter +import me.rhunk.snapenhance.download.DownloadObject import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.ImageRequestHelper class DownloadsSection : Section() { private val loadedDownloads = mutableStateOf(mapOf()) - private var currentFilter = mutableStateOf(MediaFilter.NONE) + private var currentFilter = mutableStateOf(MediaDownloadSource.NONE) + private val coroutineScope = CoroutineScope(Dispatchers.IO) override fun onResumed() { super.onResumed() - loadByFilter(currentFilter.value) + coroutineScope.launch { + loadByFilter(currentFilter.value) + } } - private fun loadByFilter(filter: MediaFilter) { + private fun loadByFilter(filter: MediaDownloadSource) { this.currentFilter.value = filter synchronized(loadedDownloads) { - loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter) + loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter).toMutableMap() + } + } + + private fun removeTask(download: DownloadObject) { + synchronized(loadedDownloads) { + loadedDownloads.value = loadedDownloads.value.toMutableMap().also { + it.remove(download.downloadId) + } + context.downloadTaskManager.removeTask(download) } } @@ -87,7 +103,6 @@ class DownloadsSection : Section() { @Composable private fun FilterList() { - val coroutineScope = rememberCoroutineScope() var showMenu by remember { mutableStateOf(false) } IconButton(onClick = { showMenu = !showMenu}) { Icon( @@ -97,7 +112,7 @@ class DownloadsSection : Section() { } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - MediaFilter.values().toList().forEach { filter -> + MediaDownloadSource.values().toList().forEach { filter -> DropdownMenuItem( text = { Row( @@ -110,7 +125,7 @@ class DownloadsSection : Section() { selected = (currentFilter.value == filter), onClick = null ) - Text(filter.name, modifier = Modifier.weight(1f)) + Text(filter.displayName, modifier = Modifier.weight(1f)) } }, onClick = { @@ -144,11 +159,12 @@ class DownloadsSection : Section() { context.androidContext, download.outputFile ), - imageLoader = context.imageLoader + imageLoader = context.imageLoader, + filterQuality = FilterQuality.None, ), modifier = Modifier .matchParentSize() - .blur(12.dp), + .blur(5.dp), contentDescription = null, contentScale = ContentScale.FillWidth ) @@ -156,9 +172,9 @@ class DownloadsSection : Section() { Row( modifier = Modifier .padding(start = 10.dp, end = 10.dp) - .fillMaxWidth() .fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically + + verticalAlignment = Alignment.CenterVertically, ){ //info card Row( @@ -177,13 +193,13 @@ class DownloadsSection : Section() { verticalArrangement = Arrangement.SpaceBetween ) { Text( - text = download.metadata.mediaDisplayType ?: "", + text = MediaDownloadSource.fromKey(download.metadata.downloadSource).displayName, overflow = TextOverflow.Ellipsis, fontSize = 16.sp, fontWeight = FontWeight.Bold ) Text( - text = download.metadata.mediaDisplaySource ?: "", + text = download.metadata.mediaAuthor ?: "", overflow = TextOverflow.Ellipsis, fontSize = 12.sp, fontWeight = FontWeight.Light @@ -191,16 +207,17 @@ class DownloadsSection : Section() { } } - Spacer(modifier = Modifier.weight(1f)) - //action buttons Row( modifier = Modifier - .padding(5.dp), + .padding(5.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { FilledIconButton( onClick = { + removeTask(download) }, colors = IconButtonDefaults.iconButtonColors( containerColor = MaterialTheme.colorScheme.error, @@ -240,6 +257,7 @@ class DownloadsSection : Section() { @Composable override fun Content() { val scrollState = rememberLazyListState() + val scope = rememberCoroutineScope() LazyColumn( state = scrollState, @@ -252,14 +270,19 @@ class DownloadsSection : Section() { item { Spacer(Modifier.height(20.dp)) if (loadedDownloads.value.isEmpty()) { - Text(text = "No downloads", fontSize = 20.sp, modifier = Modifier + Text(text = "(empty)", fontSize = 20.sp, modifier = Modifier .fillMaxWidth() .padding(10.dp), textAlign = TextAlign.Center) } - LaunchedEffect(true) { + LaunchedEffect(Unit) { val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect - lazyLoadFromIndex(lastItemIndex) - scrollState.animateScrollToItem(lastItemIndex) + scope.launch(Dispatchers.IO) { + lazyLoadFromIndex(lastItemIndex) + }.asCompletableFuture().thenAccept { + scope.launch { + scrollState.animateScrollToItem(lastItemIndex) + } + } } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt index 45f531d31..4e7ec2765 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ComposeImageHelper.kt @@ -53,6 +53,8 @@ object ImageRequestHelper { fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context) .data(filePath) .cacheKey(filePath) + .memoryCacheKey(filePath) .crossfade(true) + .crossfade(200) .build() } \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index cb351cc3c..cf6f07d2a 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -388,11 +388,12 @@ "conversation_info": "\uD83D\uDC64 Conversation Info" }, "path_format": { - "create_user_folder": "Create folder for each user", + "create_author_folder": "Create folder for each author", + "create_source_folder": "Create folder for each media source type", "append_hash": "Add a unique hash to the file name", + "append_source": "Add the media source to the file name", "append_username": "Add the username to the file name", - "append_date_time": "Add the date and time to the file name", - "append_type": "Add the media type to the file name" + "append_date_time": "Add the date and time to the file name" }, "auto_download_sources": { "friend_snaps": "Friend Snaps", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt index 7a741fc9a..d6cb698dd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -14,11 +14,12 @@ class DownloaderConfig : ConfigContainer() { ) val preventSelfAutoDownload = boolean("prevent_self_auto_download") val pathFormat = multiple("path_format", - "create_user_folder", + "create_author_folder", + "create_source_folder", "append_hash", + "append_source", + "append_username", "append_date_time", - "append_type", - "append_username" ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) } val allowDuplicate = boolean("allow_duplicate") val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt index 8a342bdc3..f18d77ad2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMetadata.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.download.data data class DownloadMetadata( val mediaIdentifier: String?, val outputPath: String, - val mediaDisplaySource: String?, - val mediaDisplayType: String?, + val mediaAuthor: String?, + val downloadSource: String, val iconUrl: String? ) \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt new file mode 100644 index 000000000..6659c499a --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaDownloadSource.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.core.download.data + +enum class MediaDownloadSource( + val key: String, + val displayName: String = key, + val pathName: String = key, + val ignoreFilter: Boolean = false +) { + NONE("none", "None", ignoreFilter = true), + PENDING("pending", "Pending", ignoreFilter = true), + CHAT_MEDIA("chat_media", "Chat Media", "chat_media"), + STORY("story", "Story", "story"), + PUBLIC_STORY("public_story", "Public Story", "public_story"), + SPOTLIGHT("spotlight", "Spotlight", "spotlight"), + PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"); + + fun matches(source: String?): Boolean { + if (source == null) return false + return source.contains(key, ignoreCase = true) + } + + companion object { + fun fromKey(key: String?): MediaDownloadSource { + if (key == null) return NONE + return values().find { it.key == key } ?: NONE + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt deleted file mode 100644 index edf591783..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaFilter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -enum class MediaFilter( - val key: String, - val shouldIgnoreFilter: Boolean = false -) { - NONE("none", true), - PENDING("pending", true), - CHAT_MEDIA("chat_media"), - STORY("story"), - SPOTLIGHT("spotlight"), - PROFILE_PICTURE("profile_picture"); - - fun matches(source: String?): Boolean { - if (source == null) return false - return source.contains(key, ignoreCase = true) - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index 61c00d5bc..e2ab736f1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -12,7 +12,7 @@ import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.InputMedia -import me.rhunk.snapenhance.core.download.data.MediaFilter +import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.download.data.toKeyPair import me.rhunk.snapenhance.core.messaging.MessagingRuleType @@ -45,16 +45,20 @@ import kotlin.coroutines.suspendCoroutine import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +private fun String.sanitizeForPath(): String { + return this.replace(" ", "_") + .replace(Regex("\\p{Cntrl}"), "") +} + @OptIn(ExperimentalEncodingApi::class) class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap? = null private var lastSeenMapParams: ParamMap? = null private fun provideDownloadManagerClient( - pathSuffix: String, mediaIdentifier: String, - mediaDisplaySource: String? = null, - mediaDisplayType: String? = null, + mediaAuthor: String, + downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null ): DownloadManagerClient { val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "") @@ -66,7 +70,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp context.shortToast(context.translation["download_processor.download_started_toast"]) } - val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix) + val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) return DownloadManagerClient( context = context, @@ -74,8 +78,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { generatedHash } else null, - mediaDisplaySource = mediaDisplaySource, - mediaDisplayType = mediaDisplayType, + mediaAuthor = mediaAuthor, + downloadSource = downloadSource.key, iconUrl = iconUrl, outputPath = outputPath ), @@ -106,13 +110,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } - //TODO: implement subfolder argument - private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String { + private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): String { val pathFormat by context.config.downloader.pathFormat - val sanitizedPathPrefix = pathPrefix - .replace(" ", "_") - .replace(Regex("[\\p{Cntrl}]"), "") - .ifEmpty { hexHash } + val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash } val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) @@ -126,19 +126,20 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } - if (pathFormat.contains("create_user_folder")) { - finalPath.append(sanitizedPathPrefix).append("/") + if (pathFormat.contains("create_author_folder")) { + finalPath.append(sanitizedMediaAuthor).append("/") + } + if (pathFormat.contains("create_source_folder")) { + finalPath.append(downloadSource.pathName).append("/") } if (pathFormat.contains("append_hash")) { appendFileName(hexHash) } - mediaDisplayType?.let { - if (pathFormat.contains("append_type")) { - appendFileName(it.lowercase().replace(" ", "-")) - } + if (pathFormat.contains("append_source")) { + appendFileName(downloadSource.pathName) } if (pathFormat.contains("append_username")) { - appendFileName(sanitizedPathPrefix) + appendFileName(sanitizedMediaAuthor) } if (pathFormat.contains("append_date_time")) { appendFileName(currentDateTime) @@ -235,10 +236,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val authorUsername = author.usernameForSorting!! downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = authorUsername, mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}", - mediaDisplaySource = authorUsername, - mediaDisplayType = MediaFilter.CHAT_MEDIA.key, + mediaAuthor = authorUsername, + downloadSource = MediaDownloadSource.CHAT_MEDIA, friendInfo = author ), mediaInfoMap) @@ -278,10 +278,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (!forceDownload && !canUseRule(author.userId!!)) return downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = authorName, mediaIdentifier = paramMap["MEDIA_ID"].toString(), - mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.STORY.key, + mediaAuthor = authorName, + downloadSource = MediaDownloadSource.STORY, friendInfo = author ), mediaInfoMap) return @@ -292,15 +291,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //public stories if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && (forceDownload || canAutoDownload("public_stories"))) { - val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( - "[\\p{Cntrl}]".toRegex(), - "") + val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").sanitizeForPath() downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = "Public-Stories/$userDisplayName", mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaDisplayType = userDisplayName, - mediaDisplaySource = "Public Story" + mediaAuthor = userDisplayName, + downloadSource = MediaDownloadSource.PUBLIC_STORY, ), mediaInfoMap) return } @@ -308,10 +304,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { downloadOperaMedia(provideDownloadManagerClient( - pathSuffix = "Spotlight", mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaDisplayType = MediaFilter.SPOTLIGHT.key, - mediaDisplaySource = paramMap["TIME_STAMP"].toString() + downloadSource = MediaDownloadSource.SPOTLIGHT, + mediaAuthor = paramMap["TIME_STAMP"].toString() ), mediaInfoMap) return } @@ -319,9 +314,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //stories with mpeg dash media //TODO: option to download multiple chapters if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { - val storyName = paramMap["STORY_NAME"].toString().replace( - "[\\p{Cntrl}]".toRegex(), - "") + val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() //get the position of the media in the playlist and the duration val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) @@ -338,20 +331,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) //get the mpd playlist and append the cdn url to baseurl nodes + context.log.verbose("Downloading dash media ${paramMap["MEDIA_ID"].toString()}", featureKey) val playlistUrl = paramMap["MEDIA_ID"].toString().let { - val urlIndex = it.indexOf("https://cf-st.sc-cdn.net") - if (urlIndex == -1) { - "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } else { - it.substring(urlIndex) - } + val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) + + urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> + it.substring(validIndex) + } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" } provideDownloadManagerClient( - pathSuffix = "Pro-Stories/${storyName}", mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", - mediaDisplaySource = storyName, - mediaDisplayType = "Pro Story" + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName ).downloadDashMedia( playlistUrl, snapChapterTimestamp, @@ -476,10 +468,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (!isPreview) { val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) provideDownloadManagerClient( - pathSuffix = authorName, mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}", - mediaDisplaySource = authorName, - mediaDisplayType = MediaFilter.CHAT_MEDIA.key, + downloadSource = MediaDownloadSource.CHAT_MEDIA, + mediaAuthor = authorName, friendInfo = friendInfo ).downloadSingleMedia( Base64.UrlSafe.encode(urlProto), @@ -532,10 +523,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp fun downloadProfilePicture(url: String, author: String) { provideDownloadManagerClient( - pathSuffix = "Profile Pictures", mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), - mediaDisplaySource = author, - mediaDisplayType = MediaFilter.PROFILE_PICTURE.key + mediaAuthor = author, + downloadSource = MediaDownloadSource.PROFILE_PICTURE ).downloadSingleMedia( url, DownloadMediaType.REMOTE_MEDIA diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt index f13cfd62d..80f6af5c5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt @@ -12,13 +12,13 @@ import javax.crypto.spec.SecretKeySpec object EncryptionHelper { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair? { - val messageMediaInfo = MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) ?: return null - val encryptionProtoIndex = if (messageMediaInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { + val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(messageProto, contentType, isArroyo) ?: return null + val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { Constants.ENCRYPTION_PROTO_INDEX_V2 } else { Constants.ENCRYPTION_PROTO_INDEX } - val encryptionProto = messageMediaInfo.followPath(encryptionProtoIndex) ?: return null + val encryptionProto = mediaEncryptionInfo.followPath(encryptionProtoIndex) ?: return null var key: ByteArray = encryptionProto.getByteArray(1)!! var iv: ByteArray = encryptionProto.getByteArray(2)!! diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt index df4a79738..bd5b76d95 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -18,7 +18,7 @@ import java.util.zip.ZipInputStream object MediaDownloaderHelper { - fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { + fun getMessageMediaEncryptionInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1) @@ -27,12 +27,13 @@ object MediaDownloaderHelper { ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath)) ContentType.EXTERNAL_MEDIA -> { val externalMediaTypes = arrayOf( - intArrayOf(3, 3), //normal external media - intArrayOf(7, 12, 3), //attached story reply - intArrayOf(7, 3) //original story reply + intArrayOf(3, 3, *mediaContainerPath), //normal external media + intArrayOf(7, 15, 1, 1), //attached audio note + intArrayOf(7, 12, 3, *mediaContainerPath), //attached story reply + intArrayOf(7, 3, *mediaContainerPath), //original story reply ) externalMediaTypes.forEach { path -> - messageContainerPath.followPath(*(path + mediaContainerPath))?.also { return it } + messageContainerPath.followPath(*path)?.also { return it } } null } From 7c5195e83cc981197fe5f0c59cd74c4a636d8022 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 12:30:15 +0200 Subject: [PATCH 009/274] feat(notifications): groups --- core/src/main/assets/lang/en_US.json | 19 +++++++------- .../snapenhance/core/config/impl/Global.kt | 4 --- .../core/config/impl/MessagingTweaks.kt | 4 +++ .../features/impl/tweaks/Notifications.kt | 25 +++++++++++++++++-- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index cf6f07d2a..8a6e0ba1b 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -203,6 +203,14 @@ "name": "Prevent Message Sending", "description": "Prevents sending certain types of messages" }, + "better_notifications": { + "name": "Better Notifications", + "description": "Adds more information in received notifications" + }, + "notification_blacklist": { + "name": "Notification Blacklist", + "description": "Select the notifications to be blocked" + }, "message_logger": { "name": "Message Logger", "description": "Keeps messages when someone deletes them. This only works for messages deleted after enabling this feature" @@ -253,14 +261,6 @@ "name": "Force Media Source Quality", "description": "Overrides the media quality to the highest possible" }, - "better_notifications": { - "name": "Better Notifications", - "description": "Adds more information in received notifications" - }, - "notification_blacklist": { - "name": "Notification Blacklist", - "description": "Select the notifications to be blocked" - }, "disable_snap_splitting": { "name": "Disable Snap Splitting", "description": "Prevents Snaps from being split into multiple parts. It also convert sent images into videos" @@ -379,7 +379,8 @@ "chat": "Show chat messages", "snap": "Show medias", "reply_button": "Add reply button", - "download_button": "Add download button" + "download_button": "Add download button", + "group": "Group notifications" }, "friend_feed_menu_buttons": { "auto_download_blacklist": "\u2B07\uFE0F Auto Download Blacklist", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt index 09daf3530..cd07dce9a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt @@ -12,9 +12,5 @@ class Global : ConfigContainer() { val disableVideoLengthRestrictions = boolean("disable_video_length_restrictions") { addNotices(FeatureNotice.MAY_BAN) } val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") val forceMediaSourceQuality = boolean("force_media_source_quality") - val betterNotifications = multiple("better_notifications", "snap", "chat", "reply_button", "download_button") - val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { - customOptionTranslationPath = "features.options.notifications" - } val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt index e82387fd5..9fb56330a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt @@ -19,6 +19,10 @@ class MessagingTweaks : ConfigContainer() { val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" } + val betterNotifications = multiple("better_notifications", "snap", "chat", "reply_button", "download_button", "group") + val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { + customOptionTranslationPath = "features.options.notifications" + } val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } val galleryMediaSendOverride = boolean("gallery_media_send_override") val messagePreviewLength = integer("message_preview_length", defaultValue = 20) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt index be27dd6da..b731a2593 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -27,6 +27,7 @@ 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.ktx.setObjectField import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper @@ -37,6 +38,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN companion object{ const val ACTION_REPLY = "me.rhunk.snapenhance.action.notification.REPLY" const val ACTION_DOWNLOAD = "me.rhunk.snapenhance.action.notification.DOWNLOAD" + const val SNAPCHAT_NOTIFICATION_GROUP = "snapchat_notification_group" } private val notificationDataQueue = mutableMapOf() // messageId => notification @@ -62,7 +64,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } private val betterNotificationFilter by lazy { - context.config.global.betterNotifications.get() + context.config.messaging.betterNotifications.get() } private fun setNotificationText(notification: Notification, conversationId: String) { @@ -185,6 +187,25 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val sendNotificationData = { notificationData: NotificationData, forceCreate: Boolean -> val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id notificationIdMap.computeIfAbsent(notificationId) { conversationId } + if (betterNotificationFilter.contains("group")) { + runCatching { + notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP) + + val summaryNotification = Notification.Builder(context.androidContext, notificationData.notification.channelId) + .setSmallIcon(notificationData.notification.smallIcon) + .setGroup(SNAPCHAT_NOTIFICATION_GROUP) + .setGroupSummary(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + + if (notificationManager.activeNotifications.firstOrNull { it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 } == null) { + notificationManager.notify(notificationData.tag, notificationData.id, summaryNotification) + } + }.onFailure { + context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", featureKey) + } + } XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( notificationData.tag, if (forceCreate) System.nanoTime().toInt() else notificationData.id, notificationData.notification, notificationData.userHandle @@ -317,7 +338,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } findClass("com.google.firebase.messaging.FirebaseMessagingService").run { - val states by context.config.global.notificationBlacklist + val states by context.config.messaging.notificationBlacklist 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(0) ?: return@hook From 94d064e0a548432049e144af10953172afe87b25 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 13:20:25 +0200 Subject: [PATCH 010/274] improve: streaks_reminder - add remaining hours - add group notifications --- .../snapenhance/messaging/StreaksReminder.kt | 33 ++++++++++++++++--- .../manager/sections/social/SocialSection.kt | 4 ++- core/src/main/assets/lang/en_US.json | 12 +++++++ .../core/config/impl/StreaksReminderConfig.kt | 2 ++ .../core/messaging/MessagingCoreObjects.kt | 8 +---- 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt index 6a3731da9..0eabe7076 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -40,13 +40,29 @@ class StreaksReminder( override fun onReceive(ctx: Context, intent: Intent) { val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx) - if (remoteSideContext.config.root.streaksReminder.globalState != true) return + val streaksReminderConfig = remoteSideContext.config.root.streaksReminder + + if (streaksReminderConfig.globalState != true) return + + val remainingHours = streaksReminderConfig.remainingHours.get() + val notifyFriendList = remoteSideContext.modDatabase.getFriends() .associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) } - .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire() } + .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) } val notificationManager = getNotificationManager(ctx) + val streaksReminderTranslation = remoteSideContext.translation.getCategory("streaks_reminder") + + if (streaksReminderConfig.groupNotifications.get() && notifyFriendList.isNotEmpty()) { + notificationManager.notify(0, NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setGroup("streaks") + .setGroupSummary(true) + .setSmallIcon(R.drawable.streak_icon) + .build()) + } notifyFriendList.forEach { (streaks, friend) -> coroutineScope.launch { @@ -56,9 +72,14 @@ class StreaksReminder( ) val notificationBuilder = NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) - .setContentTitle("Streaks") - .setContentText("You will lose streaks with ${friend.displayName} in ${streaks?.hoursLeft() ?: 0} hours") + .setContentTitle(streaksReminderTranslation["notification_title"]) + .setContentText(streaksReminderTranslation.format("notification_text", + "friend" to (friend.displayName ?: friend.mutableUsername), + "hoursLeft" to (streaks?.hoursLeft() ?: 0).toString() + )) .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setGroup("streaks") .setContentIntent(PendingIntent.getActivity( ctx, 0, @@ -74,6 +95,10 @@ class StreaksReminder( } } + if (streaksReminderConfig.groupNotifications.get()) { + notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + } + notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply { flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE }) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index ae5655c6a..3a734f1ba 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -134,6 +134,8 @@ class SocialSection : Section() { @Composable private fun ScopeList(scope: SocialScope) { + val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } + LazyColumn( modifier = Modifier .padding(2.dp) @@ -213,7 +215,7 @@ class SocialSection : Section() { imageVector = ImageVector.vectorResource(id = R.drawable.streak_icon), contentDescription = null, modifier = Modifier.height(40.dp), - tint = if (streaks.isAboutToExpire()) + tint = if (streaks.isAboutToExpire(remainingHours)) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary ) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 8a6e0ba1b..17c649191 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -308,6 +308,14 @@ "interval": { "name": "Interval", "description": "The interval between each reminder (in hours)" + }, + "remaining_hours": { + "name": "Remaining Hours", + "description": "The remaining hours before the notification is shown" + }, + "group_notifications": { + "name": "Group Notifications", + "description": "Group notifications into a single one" } } }, @@ -605,5 +613,9 @@ }, "spoof_activity": { "title": "Spoof Settings" + }, + "streaks_reminder": { + "notification_title": "Streaks", + "notification_text": "You will lose streaks with {friend} in {hoursLeft} hours" } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/StreaksReminderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/StreaksReminderConfig.kt index c8c50d9a1..d9d0fb114 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/StreaksReminderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/StreaksReminderConfig.kt @@ -4,4 +4,6 @@ import me.rhunk.snapenhance.core.config.ConfigContainer class StreaksReminderConfig : ConfigContainer(hasGlobalState = true) { val interval = integer("interval", 2) + val remainingHours = integer("remaining_hours", 13) + val groupNotifications = boolean("group_notifications", true) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt index e66c3d3eb..b8fa48e11 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt @@ -1,7 +1,6 @@ package me.rhunk.snapenhance.core.messaging import me.rhunk.snapenhance.util.SerializableDataObject -import kotlin.time.Duration.Companion.hours enum class RuleState( @@ -47,14 +46,9 @@ data class FriendStreaks( val expirationTimestamp: Long, val length: Int ) : SerializableDataObject() { - companion object { - //TODO: config - val EXPIRE_THRESHOLD = 12.hours - } - fun hoursLeft() = (expirationTimestamp - System.currentTimeMillis()) / 1000 / 60 / 60 - fun isAboutToExpire() = expirationTimestamp - System.currentTimeMillis() < EXPIRE_THRESHOLD.inWholeMilliseconds + fun isAboutToExpire(expireHours: Int) = expirationTimestamp - System.currentTimeMillis() < expireHours * 60 * 60 * 1000 } data class MessagingGroupInfo( From 89a155227794b987bb91abd003c33db9d9a50161 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 15:10:14 +0200 Subject: [PATCH 011/274] feat: force voice note audio format - add custom ffmpeg options --- app/build.gradle.kts | 1 + .../snapenhance/download/DownloadProcessor.kt | 40 ++++-- .../snapenhance/download/FFMpegProcessor.kt | 121 ++++++++++++++++++ core/build.gradle.kts | 1 - core/src/main/assets/lang/en_US.json | 34 +++++ .../snapenhance/core/config/ConfigObjects.kt | 1 + .../core/config/impl/DownloaderConfig.kt | 15 +++ .../core/download/DownloadManagerClient.kt | 17 ++- .../core/download/data/DownloadRequest.kt | 3 +- .../me/rhunk/snapenhance/data/FileType.kt | 12 +- .../impl/downloader/MediaDownloader.kt | 7 +- .../util/snap/MediaDownloaderHelper.kt | 44 ------- 12 files changed, 228 insertions(+), 68 deletions(-) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 43b8b847a..6835cbce5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,7 @@ dependencies { implementation(libs.gson) implementation(libs.coil.compose) implementation(libs.coil.video) + implementation(libs.ffmpeg.kit) implementation(libs.osmdroid.android) debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index b92b88ea7..46d3890de 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -27,7 +27,6 @@ import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair 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 @@ -63,9 +62,8 @@ class DownloadProcessor ( remoteSideContext.translation.getCategory("download_processor") } - private val gson by lazy { - GsonBuilder().setPrettyPrinting().create() - } + private val ffmpegProcessor by lazy { FFMpegProcessor(remoteSideContext.config.root.downloader.ffmpegOptions) } + private val gson by lazy { GsonBuilder().setPrettyPrinting().create() } private fun fallbackToast(message: Any) { android.os.Handler(remoteSideContext.androidContext.mainLooper).post { @@ -251,6 +249,21 @@ class DownloadProcessor ( val media = downloadedMedias[inputMedia]!! if (!downloadRequest.isDashPlaylist) { + if (inputMedia.messageContentType == "NOTE") { + remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> + val outputFile = File.createTempFile("voice_note", ".$format") + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.AUDIO_CONVERSION, + input = media.file, + output = outputFile + )) + media.file.delete() + saveMediaToGallery(outputFile, downloadObjectObject) + outputFile.delete() + return + } + } + saveMediaToGallery(media.file, downloadObjectObject) media.file.delete() return @@ -275,11 +288,13 @@ class DownloadProcessor ( callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) val outputFile = File.createTempFile("dash", ".mp4") runCatching { - MediaDownloaderHelper.downloadDashChapterFile( - dashPlaylist = dashPlaylistFile, + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.DOWNLOAD_DASH, + input = dashPlaylistFile, output = outputFile, startTime = dashOptions.offsetTime, - duration = dashOptions.duration) + duration = dashOptions.duration + )) saveMediaToGallery(outputFile, downloadObjectObject) }.onFailure { exception -> if (coroutineContext.job.isCancelled) return@onFailure @@ -370,11 +385,12 @@ class DownloadProcessor ( callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) downloadObjectObject.downloadStage = DownloadStage.MERGING - MediaDownloaderHelper.mergeOverlayFile( - media = renamedMedia, - overlay = renamedOverlayMedia, - output = mergedOverlay - ) + ffmpegProcessor.execute(FFMpegProcessor.Request( + action = FFMpegProcessor.Action.MERGE_OVERLAY, + input = renamedMedia, + output = mergedOverlay, + overlay = renamedOverlayMedia + )) saveMediaToGallery(mergedOverlay, downloadObjectObject) }.onFailure { exception -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt new file mode 100644 index 000000000..e87752d5e --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -0,0 +1,121 @@ +package me.rhunk.snapenhance.download + +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegSession +import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.config.impl.DownloaderConfig +import java.io.File +import java.util.concurrent.Executors + + +class ArgumentList : LinkedHashMap>() { + operator fun plusAssign(stringPair: Pair) { + val (key, value) = stringPair + if (this.containsKey(key)) { + this[key]!!.add(value) + } else { + this[key] = mutableListOf(value) + } + } + + operator fun plusAssign(key: String) { + this[key] = mutableListOf().apply { + this += "" + } + } + + operator fun minusAssign(key: String) { + this.remove(key) + } +} + + +class FFMpegProcessor( + private val ffmpegOptions: DownloaderConfig.FFMpegOptions +) { + enum class Action { + DOWNLOAD_DASH, + MERGE_OVERLAY, + AUDIO_CONVERSION, + } + + data class Request( + val action: Action, + val input: File, + val output: File, + val overlay: File? = null, //only for MERGE_OVERLAY + val startTime: Long? = null, //only for DOWNLOAD_DASH + val duration: Long? = null //only for DOWNLOAD_DASH + ) + + + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine { + val stringBuilder = StringBuilder() + arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> + argumentList.forEach { (key, values) -> + values.forEach valueForEach@{ value -> + if (value.isEmpty()) { + stringBuilder.append("$key ") + return@valueForEach + } + stringBuilder.append("$key $value ") + } + } + } + + Logger.directDebug("arguments: $stringBuilder", "FFMpegProcessor") + + FFmpegKit.executeAsync(stringBuilder.toString(), { session -> + it.resumeWith( + if (session.returnCode.isValueSuccess) { + Result.success(session) + } else { + Result.failure(Exception(session.output)) + } + ) + }, Executors.newSingleThreadExecutor()) + } + + suspend fun execute(args: Request) { + val globalArguments = ArgumentList().apply { + this += "-y" + this += "-threads" to ffmpegOptions.threads.get().toString() + } + + val inputArguments = ArgumentList().apply { + this += "-i" to args.input.absolutePath + } + + val outputArguments = ArgumentList().apply { + this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") + this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264") + this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") + this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } + this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" + } + + when (args.action) { + Action.DOWNLOAD_DASH -> { + outputArguments += "-ss" to "'${args.startTime}ms'" + if (args.duration != null) { + outputArguments += "-t" to "'${args.duration}ms'" + } + } + Action.MERGE_OVERLAY -> { + inputArguments += "-i" to args.overlay!!.absolutePath + outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" + } + Action.AUDIO_CONVERSION -> { + if (ffmpegOptions.customAudioCodec.isEmpty()) { + outputArguments -= "-c:a" + } + if (ffmpegOptions.customVideoCodec.isEmpty()) { + outputArguments -= "-c:v" + } + } + } + outputArguments += args.output.absolutePath + newFFMpegTask(globalArguments, inputArguments, outputArguments) + } +} \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index bde2fe836..c071dc9d1 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.recyclerview) implementation(libs.gson) - implementation(libs.ffmpeg.kit) implementation(libs.okhttp) implementation(libs.androidx.documentfile) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 17c649191..58029c629 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -115,10 +115,44 @@ "name": "Force Image Format", "description": "Forces images to be saved as a specific format" }, + "force_voice_note_format": { + "name": "Force Voice Note Format", + "description": "Forces voice notes to be saved as a specific format" + }, "chat_download_context_menu": { "name": "Chat Download Context Menu", "description": "Allows to download messages from a conversation by long pressing them" }, + "ffmpeg_options": { + "name": "FFmpeg Options", + "description": "Specify additional FFmpeg options", + "properties": { + "threads": { + "name": "Threads", + "description": "The amount of threads to use" + }, + "preset": { + "name": "Preset", + "description": "Set the speed of the conversion" + }, + "constant_rate_factor": { + "name": "Constant Rate Factor", + "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264 (lower to higher quality)" + }, + "video_bitrate": { + "name": "Video Bitrate", + "description": "Set the video bitrate (in kbps)" + }, + "custom_video_codec": { + "name": "Custom Video Codec", + "description": "Set a custom video codec (e.g. libx264)" + }, + "custom_audio_codec": { + "name": "Custom Audio Codec", + "description": "Set a custom audio codec (e.g. aac)" + } + } + }, "logging": { "name": "Logging", "description": "Shows toasts when media is downloading" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt index ef0c7219d..a8e24e05c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt @@ -65,6 +65,7 @@ class PropertyValue( fun isSet() = value != null fun getNullable() = value?.takeIf { it != "null" } + fun isEmpty() = value == null || value == "null" || value.toString().isEmpty() fun get() = getNullable() ?: throw IllegalStateException("Property is not set") fun set(value: T?) { this.value = value } @Suppress("UNCHECKED_CAST") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt index d6cb698dd..18763ab21 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -5,6 +5,17 @@ import me.rhunk.snapenhance.core.config.ConfigFlag import me.rhunk.snapenhance.core.config.FeatureNotice class DownloaderConfig : ConfigContainer() { + inner class FFMpegOptions : ConfigContainer() { + val threads = integer("threads", 1) + val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { + addFlags(ConfigFlag.NO_TRANSLATE) + } + val constantRateFactor = integer("constant_rate_factor", 30) + val videoBitrate = integer("video_bitrate", 5000) + val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } + } + val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER) } val autoDownloadSources = multiple("auto_download_sources", "friend_snaps", @@ -26,7 +37,11 @@ class DownloaderConfig : ConfigContainer() { val forceImageFormat = unique("force_image_format", "jpg", "png", "webp") { addFlags(ConfigFlag.NO_TRANSLATE) } + val forceVoiceNoteFormat = unique("force_voice_note_format", "aac", "mp3", "opus") { + addFlags(ConfigFlag.NO_TRANSLATE) + } val chatDownloadContextMenu = boolean("chat_download_context_menu") + val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { set(mutableListOf("started", "success")) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt index 648e27704..69642a007 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt @@ -10,6 +10,7 @@ import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.data.ContentType class DownloadManagerClient ( private val context: ModContext, @@ -45,15 +46,21 @@ class DownloadManagerClient ( ) } - fun downloadSingleMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { + fun downloadSingleMedia( + mediaData: String, + mediaType: DownloadMediaType, + encryption: MediaEncryptionKeyPair? = null, + messageContentType: ContentType? = null + ) { enqueueDownloadRequest( DownloadRequest( inputMedias = arrayOf( InputMedia( - content = mediaData, - type = mediaType, - encryption = encryption - ) + content = mediaData, + type = mediaType, + encryption = encryption, + messageContentType = messageContentType?.name + ) ) ) ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt index 3a2c06387..1d573a0d1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -5,7 +5,8 @@ data class DashOptions(val offsetTime: Long, val duration: Long?) data class InputMedia( val content: String, val type: DownloadMediaType, - val encryption: MediaEncryptionKeyPair? = null + val encryption: MediaEncryptionKeyPair? = null, + val messageContentType: String? = null, ) class DownloadRequest( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt index b84096b1a..ab7fd1530 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.data +import me.rhunk.snapenhance.Logger import java.io.File import java.io.InputStream @@ -14,6 +15,8 @@ enum class FileType( PNG("png", "image/png", false, true, false), MP4("mp4", "video/mp4", true, false, false), MP3("mp3", "audio/mp3",false, false, true), + OPUS("opus", "audio/opus", false, false, true), + AAC("aac", "audio/aac", false, false, true), JPG("jpg", "image/jpg",false, true, false), ZIP("zip", "application/zip", false, false, false), WEBP("webp", "image/webp", false, true, false), @@ -25,9 +28,12 @@ enum class FileType( "52494646" to WEBP, "504b0304" to ZIP, "89504e47" to PNG, - "00000020" to MP4, + "00000020" to MP4, "00000018" to MP4, "0000001c" to MP4, + "494433" to MP3, + "4f676753" to OPUS, + "fff15" to AAC, "ffd8ff" to JPG, ) @@ -55,7 +61,9 @@ enum class FileType( val headerBytes = ByteArray(16) System.arraycopy(array, 0, headerBytes, 0, 16) val hex = bytesToHex(headerBytes) - return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN + return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN.also { + Logger.directDebug("unknown file type, header: $hex", "FileType") + } } fun fromInputStream(inputStream: InputStream): FileType { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index e2ab736f1..ac3a56a42 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -473,9 +473,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaAuthor = authorName, friendInfo = friendInfo ).downloadSingleMedia( - Base64.UrlSafe.encode(urlProto), - DownloadMediaType.PROTO_MEDIA, - encryption = encryptionKeys?.toKeyPair() + mediaData = Base64.UrlSafe.encode(urlProto), + mediaType = DownloadMediaType.PROTO_MEDIA, + encryption = encryptionKeys?.toKeyPair(), + messageContentType = contentType ) return } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt index bd5b76d95..f9c366c25 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -1,8 +1,5 @@ package me.rhunk.snapenhance.util.snap -import com.arthenica.ffmpegkit.FFmpegKit -import com.arthenica.ffmpegkit.FFmpegSession -import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.data.ContentType @@ -10,10 +7,8 @@ import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.protobuf.ProtoReader import java.io.ByteArrayInputStream -import java.io.File import java.io.FileNotFoundException import java.io.InputStream -import java.util.concurrent.Executors import java.util.zip.ZipInputStream @@ -69,43 +64,4 @@ object MediaDownloaderHelper { return mapOf(SplitMediaAssetType.ORIGINAL to content) } - - - private suspend fun runFFmpegAsync(vararg args: String) = suspendCancellableCoroutine { - FFmpegKit.executeAsync(args.joinToString(" "), { session -> - it.resumeWith( - if (session.returnCode.isValueSuccess) { - Result.success(session) - } else { - Result.failure(Exception(session.output)) - } - ) - }, - Executors.newSingleThreadExecutor()) - } - - //TODO: implement setting parameters - - suspend fun downloadDashChapterFile( - dashPlaylist: File, - output: File, - startTime: Long, - duration: Long?) { - runFFmpegAsync( - "-y", "-i", dashPlaylist.absolutePath, "-ss", "'${startTime}ms'", *(if (duration != null) arrayOf("-t", "'${duration}ms'") else arrayOf()), - "-c:v", "libx264", "-preset", "ultrafast", "-threads", "6", "-q:v", "13", output.absolutePath - ) - } - - suspend fun mergeOverlayFile( - media: File, - overlay: File, - output: File - ) { - runFFmpegAsync( - "-y", "-i", media.absolutePath, "-i", overlay.absolutePath, - "-filter_complex", "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"", - "-c:v", "libx264", "-b:v", "5M", "-c:a", "copy", "-preset", "ultrafast", "-threads", "6", output.absolutePath - ) - } } \ No newline at end of file From d0e6c5571703820c31a430f883e019b97aceedcd Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 16:01:21 +0200 Subject: [PATCH 012/274] feat: disable hold to replay in FF - add setEnumField ktx - refactor AbstractWrapper delegate (nullable getValue) --- core/src/main/assets/lang/en_US.json | 4 ++++ .../me/rhunk/snapenhance/EventDispatcher.kt | 8 ++++--- .../core/config/impl/MessagingTweaks.kt | 1 + .../events/impl/OnSnapInteractionEvent.kt | 1 + .../data/wrapper/AbstractWrapper.kt | 9 ++++---- .../impl/privacy/PreventMessageSending.kt | 2 +- .../features/impl/tweaks/DisableReplayInFF.kt | 22 +++++++++++++++++++ .../features/impl/tweaks/Notifications.kt | 2 +- .../manager/impl/FeatureManager.kt | 2 ++ .../util/export/MessageExporter.kt | 6 ++--- .../snapenhance/util/ktx/XposedHelperExt.kt | 7 ++++++ 11 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 58029c629..aaf5f1eef 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -233,6 +233,10 @@ "name": "Unlimited Snap View Time", "description": "Removes the time limit for viewing Snaps" }, + "disable_replay_in_ff": { + "name": "Disable Replay in FF", + "description": "Disables the ability to replay with a long press from the friend feed" + }, "prevent_message_sending": { "name": "Prevent Message Sending", "description": "Prevents sending certain types of messages" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt index b5dd1d5a2..28b0f4576 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt @@ -35,13 +35,15 @@ class EventDispatcher( } context.classCache.snapManager.hook("onSnapInteraction", HookStage.BEFORE) { param -> + val interactionType = param.arg(0).toString() val conversationId = SnapUUID(param.arg(1)) val messageId = param.arg(2) context.event.post( OnSnapInteractionEvent( - conversationId = conversationId, - messageId = messageId - ) + interactionType = interactionType, + conversationId = conversationId, + messageId = messageId + ) )?.also { if (it.canceled) { param.setResult(null) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt index 9fb56330a..e1ab006c0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt @@ -9,6 +9,7 @@ class MessagingTweaks : ConfigContainer() { val hideBitmojiPresence = boolean("hide_bitmoji_presence") val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") + val disableReplayInFF = boolean("disable_replay_in_ff") val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", "CHAT", "SNAP", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/events/impl/OnSnapInteractionEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/events/impl/OnSnapInteractionEvent.kt index ebdaf4c86..d942ff37e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/events/impl/OnSnapInteractionEvent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/eventbus/events/impl/OnSnapInteractionEvent.kt @@ -4,6 +4,7 @@ import me.rhunk.snapenhance.core.eventbus.events.AbstractHookEvent import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID class OnSnapInteractionEvent( + val interactionType: String, val conversationId: SnapUUID, val messageId: Long ) : AbstractHookEvent() \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt index 5ffd77a23..7ea765749 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt @@ -9,8 +9,8 @@ abstract class AbstractWrapper( ) { @Suppress("UNCHECKED_CAST") inner class EnumAccessor(private val fieldName: String, private val defaultValue: T) { - operator fun getValue(obj: Any, property: KProperty<*>): T = getEnumValue(fieldName, defaultValue as Enum<*>) as T - operator fun setValue(obj: Any, property: KProperty<*>, value: Any) = setEnumValue(fieldName, value as Enum<*>) + operator fun getValue(obj: Any, property: KProperty<*>): T? = getEnumValue(fieldName, defaultValue as Enum<*>) as? T + operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) = setEnumValue(fieldName, value as Enum<*>) } companion object { @@ -32,9 +32,10 @@ abstract class AbstractWrapper( protected fun enum(fieldName: String, defaultValue: T) = EnumAccessor(fieldName, defaultValue) - fun > getEnumValue(fieldName: String, defaultValue: T): T { + fun > getEnumValue(fieldName: String, defaultValue: T?): T? { + if (defaultValue == null) return null val mContentType = XposedHelpers.getObjectField(instance, fieldName) as Enum<*> - return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) as T + return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) } @Suppress("UNCHECKED_CAST") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt index a8bddbfe2..5ada04dd2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventMessageSending.kt @@ -24,7 +24,7 @@ class PreventMessageSending : Feature("Prevent message sending", loadParams = Fe context.event.subscribe(SendMessageWithContentEvent::class) { event -> val contentType = event.messageContent.contentType - val associatedType = NotificationType.fromContentType(contentType) ?: return@subscribe + val associatedType = NotificationType.fromContentType(contentType ?: return@subscribe) ?: return@subscribe if (preventMessageSending.contains(associatedType.key)) { context.log.verbose("Preventing message sending for $associatedType") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt new file mode 100644 index 000000000..439211710 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.features.impl.tweaks + +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.hookConstructor +import me.rhunk.snapenhance.util.ktx.getObjectField +import me.rhunk.snapenhance.util.ktx.setEnumField + +class DisableReplayInFF : Feature("DisableReplayInFF", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + val state by context.config.messaging.disableReplayInFF + + findClass("com.snapchat.client.messaging.InteractionInfo") + .hookConstructor(HookStage.AFTER, { state }) { param -> + val instance = param.thisObject() + if (instance.getObjectField("mLongPressActionState").toString() == "REQUEST_SNAP_REPLAY") { + instance.setEnumField("mLongPressActionState", "SHOW_CONVERSATION_ACTION_MENU") + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt index b731a2593..2f4149288 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -221,7 +221,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } - val contentType = snapMessage.messageContent.contentType + val contentType = snapMessage.messageContent.contentType ?: return@onEach val contentData = snapMessage.messageContent.content val formatUsername: (String) -> String = { "$senderUsername: $it" } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index 739629583..e615fcc38 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -25,6 +25,7 @@ import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.tweaks.AutoSave import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks +import me.rhunk.snapenhance.features.impl.tweaks.DisableReplayInFF import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride import me.rhunk.snapenhance.features.impl.tweaks.GooglePlayServicesDialogs @@ -95,6 +96,7 @@ class FeatureManager(private val context: ModContext) : Manager { register(NoFriendScoreDelay::class) register(ProfilePictureDownloader::class) register(AddFriendSourceSpoof::class) + register(DisableReplayInFF::class) initializeFeatures() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt index ad3647029..7c4fe7fd4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt @@ -87,7 +87,7 @@ class MessageExporter( val sender = conversationParticipants[message.senderId.toString()] val senderUsername = sender?.usernameForSorting ?: message.senderId.toString() val senderDisplayName = sender?.displayName ?: message.senderId.toString() - val messageContent = serializeMessageContent(message) ?: message.messageContent.contentType.name + val messageContent = serializeMessageContent(message) ?: message.messageContent.contentType?.name val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(message.messageMetadata.createdAt)) writer.write("[$date] - $senderDisplayName (${senderUsername}): $messageContent\n") } @@ -118,7 +118,7 @@ class MessageExporter( runCatching { val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { - EncryptionHelper.decryptInputStream(it, message.messageContent.contentType, ProtoReader(message.messageContent.content), isArroyo = false) + EncryptionHelper.decryptInputStream(it, message.messageContent.contentType!!, ProtoReader(message.messageContent.content), isArroyo = false) } printLog("downloaded media ${message.orderKey}") @@ -276,7 +276,7 @@ class MessageExporter( addProperty("serializedContent", serializeMessageContent(message)) addProperty("rawContent", Base64.getUrlEncoder().encodeToString(message.messageContent.content)) - val messageContentType = message.messageContent.contentType + val messageContentType = message.messageContent.contentType ?: ContentType.CHAT EncryptionHelper.getEncryptionKeys(messageContentType, ProtoReader(message.messageContent.content), isArroyo = false)?.let { encryptionKeyPair -> add("encryption", JsonObject().apply encryption@{ diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt index 811497887..032023788 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt @@ -6,6 +6,13 @@ fun Any.getObjectField(fieldName: String): Any? { return XposedHelpers.getObjectField(this, fieldName) } +fun Any.setEnumField(fieldName: String, value: String) { + this::class.java.getDeclaredField(fieldName) + .type.enumConstants?.firstOrNull { it.toString() == value }?.let { enum -> + setObjectField(fieldName, enum) + } +} + fun Any.setObjectField(fieldName: String, value: Any?) { XposedHelpers.setObjectField(this, fieldName, value) } From a3edd40cfbb188ee21ddf2233092aab2ce304aee Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 16:37:08 +0200 Subject: [PATCH 013/274] fix(send_override): media extras (mentions, places, ...) --- .../rhunk/snapenhance/data/MessageSender.kt | 25 +++++++++++-------- ...ryMediaSendOverride.kt => SendOverride.kt} | 8 +++--- .../manager/impl/FeatureManager.kt | 4 +-- 3 files changed, 22 insertions(+), 15 deletions(-) rename core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/{GalleryMediaSendOverride.kt => SendOverride.kt} (95%) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt index 4578988a4..f1c522510 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt @@ -12,20 +12,25 @@ class MessageSender( private val context: ModContext, ) { companion object { - val redSnapProto: () -> ByteArray = { + val redSnapProto: (ByteArray?) -> ByteArray = { extras -> ProtoWriter().apply { - from(11, 5) { - from(1) { + from(11) { + from(5) { from(1) { - addVarInt(2, 0) - addVarInt(12, 0) - addVarInt(15, 0) + from(1) { + addVarInt(2, 0) + addVarInt(12, 0) + addVarInt(15, 0) + } + addVarInt(6, 0) + } + from(2) { + addVarInt(5, 1) // audio by default + addBuffer(6, byteArrayOf()) } - addVarInt(6, 0) } - from(2) { - addVarInt(5, 1) // audio by default - addBuffer(6, byteArrayOf()) + extras?.let { + addBuffer(13, it) } } }.toByteArray() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt similarity index 95% rename from core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt index a36bea03f..c32fc8950 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt @@ -11,7 +11,7 @@ import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.util.protobuf.ProtoEditor import me.rhunk.snapenhance.util.protobuf.ProtoReader -class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { +class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { private var isLastSnapSavable = false override fun init() { @@ -37,7 +37,6 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara // only affect snaps if (!it.containsPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11)) return@subscribe } - isLastSnapSavable = false event.buffer = ProtoEditor(event.buffer).apply { //remove the max view time @@ -59,6 +58,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara context.event.subscribe(SendMessageWithContentEvent::class, { context.config.messaging.galleryMediaSendOverride.get() }) { event -> + isLastSnapSavable = false val localMessageContent = event.messageContent if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA) return@subscribe @@ -86,8 +86,10 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara when (overrideType) { "SNAP", "SAVABLE_SNAP" -> { + val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer() + localMessageContent.contentType = ContentType.SNAP - localMessageContent.content = MessageSender.redSnapProto() + localMessageContent.content = MessageSender.redSnapProto(extras) if (overrideType == "SAVABLE_SNAP") { isLastSnapSavable = true } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index e615fcc38..fdb4fd075 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -27,7 +27,7 @@ import me.rhunk.snapenhance.features.impl.tweaks.AutoSave import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.features.impl.tweaks.DisableReplayInFF import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction -import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride +import me.rhunk.snapenhance.features.impl.tweaks.SendOverride import me.rhunk.snapenhance.features.impl.tweaks.GooglePlayServicesDialogs import me.rhunk.snapenhance.features.impl.tweaks.LocationSpoofer import me.rhunk.snapenhance.features.impl.tweaks.MediaQualityLevelOverride @@ -77,7 +77,7 @@ class FeatureManager(private val context: ModContext) : Manager { register(AutoSave::class) register(UITweaks::class) register(ConfigurationOverride::class) - register(GalleryMediaSendOverride::class) + register(SendOverride::class) register(UnlimitedSnapViewTime::class) register(DisableVideoLengthRestriction::class) register(MediaQualityLevelOverride::class) From 6b9938b8b2702a10164ae9e05a67308aaa9409eb Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 18:15:40 +0200 Subject: [PATCH 014/274] feat: permission screen - single context coroutine scope - refactor activity launcher helper - move updater to home section --- .../me/rhunk/snapenhance/RemoteSideContext.kt | 5 +- .../snapenhance/download/DownloadProcessor.kt | 2 +- .../snapenhance/messaging/StreaksReminder.kt | 6 +- .../ui/manager/data/InstallationSummary.kt | 3 +- .../snapenhance/ui/manager/data/Updater.kt | 31 +++++ .../sections/downloads/DownloadsSection.kt | 6 +- .../ui/manager/sections/home/HomeSection.kt | 131 +++++++++++++----- .../snapenhance/ui/setup/Requirements.kt | 2 + .../snapenhance/ui/setup/SetupActivity.kt | 4 + .../setup/screens/impl/PermissionsScreen.kt | 115 +++++++++++++++ .../ui/util/ActivityLauncherHelper.kt | 57 ++++++-- .../rhunk/snapenhance/ui/util/AlertDialogs.kt | 1 - .../snapenhance/core/config/impl/Global.kt | 2 - .../snapenhance/features/impl/AutoUpdater.kt | 114 --------------- .../manager/impl/FeatureManager.kt | 4 +- 15 files changed, 297 insertions(+), 186 deletions(-) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 4a1c0dbdf..8209044e5 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -14,6 +14,7 @@ import coil.ImageLoader import coil.decode.VideoFrameDecoder import coil.disk.DiskCache import coil.memory.MemoryCache +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.rhunk.snapenhance.bridge.BridgeService import me.rhunk.snapenhance.core.BuildConfig @@ -71,6 +72,7 @@ class RemoteSideContext( } .components { add(VideoFrameDecoder.Factory()) }.build() } + val coroutineScope = CoroutineScope(Dispatchers.IO) fun reload() { runCatching { @@ -103,7 +105,7 @@ class RemoteSideContext( ) }, modInfo = ModInfo( - loaderPackageName = MainActivity::class.java.`package`?.name ?: "unknown", + loaderPackageName = MainActivity::class.java.`package`?.name, buildPackageName = BuildConfig.APPLICATION_ID, buildVersion = BuildConfig.VERSION_NAME, buildVersionCode = BuildConfig.VERSION_CODE.toLong(), @@ -119,7 +121,6 @@ class RemoteSideContext( ), platformInfo = PlatformInfo( device = Build.DEVICE, - buildFingerprint = Build.FINGERPRINT, androidVersion = Build.VERSION.RELEASE, systemAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 46d3890de..a9a226600 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -316,7 +316,7 @@ class DownloadProcessor ( } fun onReceive(intent: Intent) { - CoroutineScope(Dispatchers.IO).launch { + remoteSideContext.coroutineScope.launch { val downloadMetadata = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java) val downloadRequest = gson.fromJson(intent.getStringExtra(DownloadManagerClient.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt index 0eabe7076..3d0d57aeb 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -9,8 +9,6 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.graphics.drawable.toBitmap -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.R import me.rhunk.snapenhance.RemoteSideContext @@ -26,8 +24,6 @@ class StreaksReminder( private const val NOTIFICATION_CHANNEL_ID = "streaks" } - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private fun getNotificationManager(context: Context) = context.getSystemService(NotificationManager::class.java).apply { createNotificationChannel( NotificationChannel( @@ -65,7 +61,7 @@ class StreaksReminder( } notifyFriendList.forEach { (streaks, friend) -> - coroutineScope.launch { + remoteSideContext.coroutineScope.launch { val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D) val bitmojiImage = remoteSideContext.imageLoader.execute( ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt index 24e73e0cb..c8af6d0d3 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt @@ -10,7 +10,7 @@ data class SnapchatAppInfo( ) data class ModInfo( - val loaderPackageName: String, + val loaderPackageName: String?, val buildPackageName: String, val buildVersion: String, val buildVersionCode: Long, @@ -22,7 +22,6 @@ data class ModInfo( data class PlatformInfo( val device: String, - val buildFingerprint: String, val androidVersion: String, val systemAbi: String, ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt new file mode 100644 index 000000000..f68f9bc1d --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/Updater.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.ui.manager.data + +import com.google.gson.JsonParser +import me.rhunk.snapenhance.core.BuildConfig +import okhttp3.OkHttpClient +import okhttp3.Request + + +object Updater { + data class LatestRelease( + val versionName: String, + val releaseUrl: String + ) + + fun checkForLatestRelease(): LatestRelease? { + val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() + val response = OkHttpClient().newCall(endpoint).execute() + + if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}") + + val releases = JsonParser.parseString(response.body.string()).asJsonArray.also { + if (it.size() == 0) throw Throwable("No releases found") + } + + val latestRelease = releases.get(0).asJsonObject + val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString + if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return null + + return LatestRelease(latestVersion, endpoint.url.toString().replace("api.", "").replace("repos/", "")) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt index 2e4cb3d85..1514d8ead 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.rememberAsyncImagePainter -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch @@ -64,11 +63,10 @@ import me.rhunk.snapenhance.ui.util.ImageRequestHelper class DownloadsSection : Section() { private val loadedDownloads = mutableStateOf(mapOf()) private var currentFilter = mutableStateOf(MediaDownloadSource.NONE) - private val coroutineScope = CoroutineScope(Dispatchers.IO) override fun onResumed() { super.onResumed() - coroutineScope.launch { + context.coroutineScope.launch { loadByFilter(currentFilter.value) } } @@ -129,7 +127,7 @@ class DownloadsSection : Section() { } }, onClick = { - coroutineScope.launch { + context.coroutineScope.launch { loadByFilter(filter) showMenu = false } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 0c384f176..329e90ccf 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.ui.manager.sections.home +import android.content.Intent import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState @@ -48,9 +49,11 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navigation +import kotlinx.coroutines.launch import me.rhunk.snapenhance.R import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.data.InstallationSummary +import me.rhunk.snapenhance.ui.manager.data.Updater import me.rhunk.snapenhance.ui.setup.Requirements import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.saveFile @@ -64,9 +67,10 @@ class HomeSection : Section() { const val LOGS_SECTION_ROUTE = "home_logs" } - private val installationSummary = mutableStateOf(null as InstallationSummary?) - private val userLocale = mutableStateOf(null as String?) + private var installationSummary: InstallationSummary? = null + private var userLocale: String? = null private val homeSubSection by lazy { HomeSubSection(context) } + private var latestUpdate: Updater.LatestRelease? = null private lateinit var activityLauncherHelper: ActivityLauncherHelper override fun init() { @@ -100,42 +104,16 @@ class HomeSection : Section() { @Composable private fun SummaryCards(installationSummary: InstallationSummary) { - OutlinedCard( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth() - ) { - SummaryCardRow( - icon = Icons.Filled.Map, - title = if (installationSummary.modInfo == null || installationSummary.modInfo.mappingsOutdated == true) { - "Mappings ${if (installationSummary.modInfo == null) "not generated" else "outdated"}" - } else { - "Mappings version ${installationSummary.modInfo.mappingVersion}" - } - ) { - Button(onClick = { - context.checkForRequirements(Requirements.MAPPINGS) - }, modifier = Modifier.height(40.dp)) { - Icon(Icons.Filled.Refresh, contentDescription = null) - } - } - - SummaryCardRow(icon = Icons.Filled.Language, title = userLocale.value ?: "Unknown") { - Button(onClick = { - context.checkForRequirements(Requirements.LANGUAGE) - }, modifier = Modifier.height(40.dp)) { - Icon(Icons.Filled.OpenInNew, contentDescription = null) - } - } - } - val summaryInfo = remember { mapOf( "Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), + "Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"), + "Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"), + "Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"), + "Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"), "Device" to installationSummary.platformInfo.device, - "Android version" to installationSummary.platformInfo.androidVersion, - "System ABI" to installationSummary.platformInfo.systemAbi, - "Build fingerprint" to installationSummary.platformInfo.buildFingerprint + "Android Version" to installationSummary.platformInfo.androidVersion, + "System ABI" to installationSummary.platformInfo.systemAbi ) } @@ -172,7 +150,35 @@ class HomeSection : Section() { } } } + } + + OutlinedCard( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth() + ) { + SummaryCardRow( + icon = Icons.Filled.Map, + title = if (installationSummary.modInfo == null || installationSummary.modInfo.mappingsOutdated == true) { + "Mappings ${if (installationSummary.modInfo == null) "not generated" else "outdated"}" + } else { + "Mappings version ${installationSummary.modInfo.mappingVersion}" + } + ) { + Button(onClick = { + context.checkForRequirements(Requirements.MAPPINGS) + }, modifier = Modifier.height(40.dp)) { + Icon(Icons.Filled.Refresh, contentDescription = null) + } + } + SummaryCardRow(icon = Icons.Filled.Language, title = userLocale ?: "Unknown") { + Button(onClick = { + context.checkForRequirements(Requirements.LANGUAGE) + }, modifier = Modifier.height(40.dp)) { + Icon(Icons.Filled.OpenInNew, contentDescription = null) + } + } } } @@ -180,8 +186,19 @@ class HomeSection : Section() { if (!context.mappings.isMappingsLoaded()) { context.mappings.init(context.androidContext) } - installationSummary.value = context.installationSummary - userLocale.value = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) + context.coroutineScope.launch { + userLocale = context.translation.loadedLocale.getDisplayName(Locale.getDefault()) + runCatching { + installationSummary = context.installationSummary + }.onFailure { + context.longToast("SnapEnhance failed to load installation summary: ${it.message}") + } + runCatching { + latestUpdate = Updater.checkForLatestRelease() + }.onFailure { + context.longToast("SnapEnhance failed to check for updates: ${it.message}") + } + } } override fun sectionTopBarName(): String { @@ -304,13 +321,53 @@ class HomeSection : Section() { ) } + if (latestUpdate != null) { + OutlinedCard( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ){ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 15.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "SnapEnhance Update", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + Text( + fontSize = 12.sp, + text = "Version ${latestUpdate?.versionName} is available!", + lineHeight = 20.sp + ) + } + Button(onClick = { + context.activity?.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(latestUpdate?.releaseUrl) + } + ) + }, modifier = Modifier.height(40.dp)) { + Text(text = "Download") + } + } + } + } Text( text = "An xposed module that enhances the Snapchat experience", modifier = Modifier.padding(16.dp) ) - SummaryCards(installationSummary = installationSummary.value ?: return) + SummaryCards(installationSummary = installationSummary ?: return) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt index 8207f6c3c..04235b784 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/Requirements.kt @@ -5,6 +5,7 @@ object Requirements { const val LANGUAGE = 0b00010 const val MAPPINGS = 0b00100 const val SAVE_FOLDER = 0b01000 + const val GRANT_PERMISSIONS = 0b10000 fun getName(requirement: Int): String { return when (requirement) { @@ -12,6 +13,7 @@ object Requirements { LANGUAGE -> "LANGUAGE" MAPPINGS -> "MAPPINGS" SAVE_FOLDER -> "SAVE_FOLDER" + GRANT_PERMISSIONS -> "GRANT_PERMISSIONS" else -> "UNKNOWN" } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt index 98fa87d90..0a8037c7c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt @@ -36,6 +36,7 @@ import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.ui.AppMaterialTheme import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.setup.screens.impl.MappingsScreen +import me.rhunk.snapenhance.ui.setup.screens.impl.PermissionsScreen import me.rhunk.snapenhance.ui.setup.screens.impl.PickLanguageScreen import me.rhunk.snapenhance.ui.setup.screens.impl.SaveFolderScreen @@ -65,6 +66,9 @@ class SetupActivity : ComponentActivity() { if (isFirstRun || hasRequirement(Requirements.LANGUAGE)) { add(PickLanguageScreen().apply { route = "language" }) } + if (isFirstRun || hasRequirement(Requirements.GRANT_PERMISSIONS)) { + add(PermissionsScreen().apply { route = "permissions" }) + } if (isFirstRun || hasRequirement(Requirements.SAVE_FOLDER)) { add(SaveFolderScreen().apply { route = "saveFolder" }) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt new file mode 100644 index 000000000..7fd872fb4 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt @@ -0,0 +1,115 @@ +package me.rhunk.snapenhance.ui.setup.screens.impl + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.ui.setup.screens.SetupScreen +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper + +class PermissionsScreen : SetupScreen() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + + @SuppressLint("BatteryLife") + @Composable + override fun Content() { + var notificationPermissionGranted by remember { mutableStateOf(true) } + var isBatteryOptimisationIgnored by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionGranted = context.androidContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + val powerManager = context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager + isBatteryOptimisationIgnored = powerManager.isIgnoringBatteryOptimizations(context.androidContext.packageName) + } + + if (isBatteryOptimisationIgnored && notificationPermissionGranted) { + allowNext(true) + } else { + allowNext(false) + } + + DialogText(text = "To continue you need to fit the following requirements:") + + OutlinedCard( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .padding(5.dp) + ) { + Row( + horizontalArrangement = Arrangement.Absolute.SpaceAround + ) { + DialogText(text = "Notification access", modifier = Modifier.weight(1f)) + if (notificationPermissionGranted) { + DialogText(text = "Granted") + } else { + Button(onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activityLauncherHelper.requestPermission(Manifest.permission.POST_NOTIFICATIONS) { resultCode, _ -> + coroutineScope.launch { + notificationPermissionGranted = resultCode == ComponentActivity.RESULT_OK + } + } + } + }) { + Text(text = "Request") + } + } + } + Row { + DialogText(text = "Battery optimisation", modifier = Modifier.weight(1f)) + if (isBatteryOptimisationIgnored) { + DialogText(text = "Ignored") + } else { + Button(onClick = { + activityLauncherHelper.launch(Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.androidContext.packageName}") + }) { resultCode, _ -> + coroutineScope.launch { + isBatteryOptimisationIgnored = resultCode == 0 + } + } + }) { + Text(text = "Request") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt index 009e33400..f9336a01f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt @@ -6,29 +6,47 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import me.rhunk.snapenhance.Logger +typealias ActivityLauncherCallback = (resultCode: Int, intent: Intent?) -> Unit + class ActivityLauncherHelper( - val activity: ComponentActivity + val activity: ComponentActivity, ) { - private var callback: ((Intent) -> Unit)? = null + private var callback: ActivityLauncherCallback? = null + private var permissionResultLauncher: ActivityResultLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { result -> + runCatching { + callback?.let { it(if (result) ComponentActivity.RESULT_OK else ComponentActivity.RESULT_CANCELED, null) } + }.onFailure { + Logger.directError("Failed to process activity result", it) + } + callback = null + } + private var activityResultLauncher: ActivityResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == ComponentActivity.RESULT_OK) { - runCatching { - callback?.let { it(result.data!!) } - }.onFailure { - Logger.directError("Failed to process activity result", it) - } + runCatching { + callback?.let { it(result.resultCode, result.data) } + }.onFailure { + Logger.directError("Failed to process activity result", it) } callback = null } - fun launch(intent: Intent, callback: (Intent) -> Unit) { + fun launch(intent: Intent, callback: ActivityLauncherCallback) { if (this.callback != null) { throw IllegalStateException("Already launching an activity") } this.callback = callback activityResultLauncher.launch(intent) } + + fun requestPermission(permission: String, callback: ActivityLauncherCallback) { + if (this.callback != null) { + throw IllegalStateException("Already launching an activity") + } + this.callback = callback + permissionResultLauncher.launch(permission) + } } fun ActivityLauncherHelper.chooseFolder(callback: (uri: String) -> Unit) { @@ -36,8 +54,11 @@ fun ActivityLauncherHelper.chooseFolder(callback: (uri: String) -> Unit) { Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - ) { - val uri = it.data ?: return@launch + ) { resultCode, intent -> + if (resultCode != ComponentActivity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch val value = uri.toString() this.activity.contentResolver.takePersistableUriPermission( uri, @@ -55,8 +76,11 @@ fun ActivityLauncherHelper.saveFile(name: String, type: String = "*/*", callback .putExtra(Intent.EXTRA_TITLE, name) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - ) { - val uri = it.data ?: return@launch + ) { resultCode, intent -> + if (resultCode != ComponentActivity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch val value = uri.toString() this.activity.contentResolver.takePersistableUriPermission( uri, @@ -72,8 +96,11 @@ fun ActivityLauncherHelper.openFile(type: String = "*/*", callback: (uri: String .setType(type) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - ) { - val uri = it.data ?: return@launch + ) { resultCode, intent -> + if (resultCode != ComponentActivity.RESULT_OK) { + return@launch + } + val uri = intent?.data ?: return@launch val value = uri.toString() this.activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) callback(value) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt index e30a39086..29d24de04 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -101,7 +101,6 @@ class AlertDialogs( Text( text = title, fontSize = 20.sp, - fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 5.dp, bottom = 10.dp) ) if (message != null) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt index cd07dce9a..2fbce0588 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt @@ -2,11 +2,9 @@ package me.rhunk.snapenhance.core.config.impl import me.rhunk.snapenhance.core.config.ConfigContainer import me.rhunk.snapenhance.core.config.FeatureNotice -import me.rhunk.snapenhance.data.NotificationType class Global : ConfigContainer() { val snapchatPlus = boolean("snapchat_plus") { addNotices(FeatureNotice.MAY_BAN) } - val autoUpdater = unique("auto_updater", "EVERY_LAUNCH", "DAILY", "WEEKLY").apply { set("DAILY") } val disableMetrics = boolean("disable_metrics") val blockAds = boolean("block_ads") val disableVideoLengthRestrictions = boolean("disable_video_length_restrictions") { addNotices(FeatureNotice.MAY_BAN) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt deleted file mode 100644 index 8b89a0970..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/AutoUpdater.kt +++ /dev/null @@ -1,114 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import android.annotation.SuppressLint -import android.app.DownloadManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.Uri -import android.os.Build -import android.os.Environment -import com.google.gson.JsonParser -import me.rhunk.snapenhance.core.BuildConfig -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import okhttp3.OkHttpClient -import okhttp3.Request - -class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val autoUpdaterTime = context.config.global.autoUpdater.getNullable() ?: return - val currentTimeMillis = System.currentTimeMillis() - val checkForUpdatesTimestamp = context.bridgeClient.getAutoUpdaterTime() - - val delayTimestamp = when (autoUpdaterTime) { - "EVERY_LAUNCH" -> currentTimeMillis - checkForUpdatesTimestamp - "DAILY" -> 86400000L - "WEEKLY" -> 604800000L - else -> return - } - - if (checkForUpdatesTimestamp + delayTimestamp > currentTimeMillis) return - - runCatching { - checkForUpdates() - }.onFailure { - context.log.error("Failed to check for updates: ${it.message}", it) - }.onSuccess { - context.bridgeClient.setAutoUpdaterTime(currentTimeMillis) - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - fun checkForUpdates(): String? { - val endpoint = Request.Builder().url("https://api.github.com/repos/rhunk/SnapEnhance/releases").build() - val response = OkHttpClient().newCall(endpoint).execute() - - if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}") - - val releases = JsonParser.parseString(response.body.string()).asJsonArray.also { - if (it.size() == 0) throw Throwable("No releases found") - } - - val latestRelease = releases.get(0).asJsonObject - val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString - if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return null - - val architectureName = Build.SUPPORTED_ABIS.let { - if (it.contains("arm64-v8a")) return@let "armv8" - if (it.contains("armeabi-v7a") || it.contains("armeabi")) return@let "armv7" - throw Throwable("Failed getting architecture") - } - - val releaseContentBody = latestRelease.getAsJsonPrimitive("body").asString - val downloadEndpoint = "https://github.com/rhunk/SnapEnhance/releases/download/${latestVersion}/app-${latestVersion.removePrefix("v")}-${architectureName}-release-signed.apk" - - context.runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(context.translation["auto_updater.dialog_title"]) - .setMessage( - context.translation.format("auto_updater.dialog_message", - "version" to latestVersion, - "body" to releaseContentBody) - ) - .setNegativeButton(context.translation["auto_updater.dialog_negative_button"]) { dialog, _ -> - dialog.dismiss() - } - .setPositiveButton(context.translation["auto_updater.dialog_positive_button"]) { dialog, _ -> - dialog.dismiss() - context.longToast(context.translation["auto_updater.downloading_toast"]) - - val request = DownloadManager.Request(Uri.parse(downloadEndpoint)) - .setTitle(context.translation["auto_updater.download_manager_notification_title"]) - .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "latest-snapenhance.apk") - .setMimeType("application/vnd.android.package-archive") - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - - val downloadManager = context.androidContext.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val downloadId = downloadManager.enqueue(request) - - val onCompleteReceiver = object: BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) - if (id != downloadId) return - context.unregisterReceiver(this) - context.startActivity( - Intent(Intent.ACTION_VIEW).apply { - setDataAndType(downloadManager.getUriForDownloadedFile(downloadId), "application/vnd.android.package-archive") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } - } - - context.mainActivity?.registerReceiver(onCompleteReceiver, IntentFilter( - DownloadManager.ACTION_DOWNLOAD_COMPLETE - )) - }.show() - } - - return latestVersion - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index fdb4fd075..018062a29 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -4,7 +4,6 @@ import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.AutoUpdater import me.rhunk.snapenhance.features.impl.ConfigurationOverride import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader @@ -27,11 +26,11 @@ import me.rhunk.snapenhance.features.impl.tweaks.AutoSave import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.features.impl.tweaks.DisableReplayInFF import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction -import me.rhunk.snapenhance.features.impl.tweaks.SendOverride 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.SendOverride import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime import me.rhunk.snapenhance.features.impl.ui.PinConversations @@ -84,7 +83,6 @@ class FeatureManager(private val context: ModContext) : Manager { register(MeoPasscodeBypass::class) register(AppPasscode::class) register(LocationSpoofer::class) - register(AutoUpdater::class) register(CameraTweaks::class) register(InfiniteStoryBoost::class) register(AmoledDarkMode::class) From 24fc945f1aeefd4344125ccb8c2ad70713e80814 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:15:34 +0200 Subject: [PATCH 015/274] chore(lang): friend feed buttons --- core/src/main/assets/lang/en_US.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index aaf5f1eef..cda4214cc 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -429,9 +429,9 @@ "group": "Group notifications" }, "friend_feed_menu_buttons": { - "auto_download_blacklist": "\u2B07\uFE0F Auto Download Blacklist", - "anti_auto_save": "\uD83D\uDCAC Anti Auto Save Messages", - "stealth_mode": "\uD83D\uDC7B Stealth Mode", + "auto_download": "\u2B07\uFE0F Auto Download", + "auto_save": "\uD83D\uDCAC Auto Save Messages", + "stealth": "\uD83D\uDC7B Stealth Mode", "conversation_info": "\uD83D\uDC64 Conversation Info" }, "path_format": { From 600dec7fc64a7d9a3d448d8ce759279c34aef1aa Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:19:25 +0200 Subject: [PATCH 016/274] bridge getRulesIds --- .../me/rhunk/snapenhance/bridge/BridgeService.kt | 4 ++++ .../me/rhunk/snapenhance/messaging/ModDatabase.kt | 10 ++++++++++ .../me/rhunk/snapenhance/bridge/BridgeInterface.aidl | 7 +++++++ .../me/rhunk/snapenhance/core/bridge/BridgeClient.kt | 4 ++++ .../snapenhance/features/impl/ui/PinConversations.kt | 2 +- .../kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt | 6 ++---- 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt index 84dd01376..7c807cf52 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -128,6 +128,10 @@ class BridgeService : Service() { return remoteSideContext.modDatabase.getRules(uuid).map { it.key } } + override fun getRuleIds(type: String): MutableList { + return remoteSideContext.modDatabase.getRuleIds(type) + } + override fun setRule(uuid: String, rule: String, state: Boolean) { remoteSideContext.modDatabase.setRule(uuid, rule, state) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt index 978b5aa45..1370e9ebb 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -241,4 +241,14 @@ class ModDatabase( )) } } + + fun getRuleIds(type: String): MutableList { + return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor -> + val ruleIds = mutableListOf() + while (cursor.moveToNext()) { + ruleIds.add(cursor.getStringOrNull("targetUuid")!!) + } + ruleIds + } + } } \ No newline at end of file diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl index c20ac698f..9e04f2db0 100644 --- a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl +++ b/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -60,6 +60,13 @@ interface BridgeInterface { */ List getRules(String uuid); + /** + * Get all ids for a specific rule + * @param type rule type (MessagingRuleType) + * @return list of ids + */ + List getRuleIds(String type); + /** * Update rule for a giver user or conversation * diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt index a1cf662b9..dc8e7c06a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -142,6 +142,10 @@ class BridgeClient( return service.getRules(targetUuid).map { MessagingRuleType.getByName(it) } } + fun getRuleIds(ruleType: MessagingRuleType): List { + return service.getRuleIds(ruleType.key) + } + fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = service.setRule(targetUuid, type.key, state) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt index a7098ef4c..5b6b57c8e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt @@ -29,7 +29,7 @@ class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PI context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) + val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) val isPinned = exists(conversationUUID.toString()) if (isPinned) { instance.setObjectField("mPinnedTimestampMs", 1L) diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt index 9f42b6434..f5e2904e9 100644 --- a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt +++ b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt @@ -12,12 +12,10 @@ class CallbackMapper : AbstractClassMapper() { if (clazz.superclass == null) return@filter false val superclassName = clazz.getSuperClassName()!! - if (!superclassName.endsWith("Callback") || superclassName.endsWith("\$Callback")) return@filter false + if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate")) || 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" } + !superClass.isFinal() }.map { it.getSuperClassName()!!.substringAfterLast("/") to it.getClassName() } From 0b626df1ebcef262702a46e8641f5ba9c8466718 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 19:27:56 +0200 Subject: [PATCH 017/274] fix(media_downloader): unavailable proto toast --- .../features/impl/downloader/MediaDownloader.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index ac3a56a42..32d628a69 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -454,14 +454,17 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } val messageReader = ProtoReader(messageContent) - val urlProto: ByteArray = if (isArroyoMessage) { + val urlProto: ByteArray? = if (isArroyoMessage) { var finalProto: ByteArray? = null messageReader.eachBuffer(4, 5) { finalProto = getByteArray(1, 3) } - finalProto!! - } else { - deletedMediaReference!! + finalProto + } else deletedMediaReference + + if (urlProto == null) { + context.shortToast(translations["unsupported_content_type_toast"]) + return } runCatching { From ec05e4f5d4d43a281a673a911195108aec9c72f0 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 20:04:31 +0200 Subject: [PATCH 018/274] feat(media_downloader): ability to select chapters for dash media --- .../snapenhance/download/FFMpegProcessor.kt | 1 + core/src/main/assets/lang/en_US.json | 4 + .../core/config/impl/DownloaderConfig.kt | 1 + .../impl/downloader/MediaDownloader.kt | 93 +++++++++++++++---- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt index e87752d5e..80cabbbbd 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -93,6 +93,7 @@ class FFMpegProcessor( this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" + this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K" } when (args.action) { diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index cda4214cc..e1d2e7adb 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -143,6 +143,10 @@ "name": "Video Bitrate", "description": "Set the video bitrate (in kbps)" }, + "audio_bitrate": { + "name": "Audio Bitrate", + "description": "Set the audio bitrate (in kbps)" + }, "custom_video_codec": { "name": "Custom Video Codec", "description": "Set a custom video codec (e.g. libx264)" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt index 18763ab21..accfe4ae1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -12,6 +12,7 @@ class DownloaderConfig : ConfigContainer() { } val constantRateFactor = integer("constant_rate_factor", 30) val videoBitrate = integer("video_bitrate", 5000) + val audioBitrate = integer("audio_bitrate", 128) val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index 32d628a69..b8233079f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -50,6 +50,12 @@ private fun String.sanitizeForPath(): String { .replace(Regex("\\p{Cntrl}"), "") } +class SnapChapterInfo( + val offset: Long, + val duration: Long? +) + + @OptIn(ExperimentalEncodingApi::class) class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap? = null @@ -312,26 +318,25 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } //stories with mpeg dash media - //TODO: option to download multiple chapters if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() - //get the position of the media in the playlist and the duration val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters + val currentChapterIndex = snapChapterList.indexOfFirst { it.snapId == snapItem.snapId } + if (snapChapterList.isEmpty()) { context.shortToast("No chapters found") return } - val snapChapter = snapChapterList.first { it.snapId == snapItem.snapId } - val nextChapter = snapChapterList.getOrNull(snapChapterList.indexOf(snapChapter) + 1) - //add 100ms to the start time to prevent the video from starting too early - val snapChapterTimestamp = snapChapter.startTimeMs.plus(100) - val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) + fun prettyPrintTime(time: Long): String { + val seconds = time / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + return "${hours % 24}:${minutes % 60}:${seconds % 60}" + } - //get the mpd playlist and append the cdn url to baseurl nodes - context.log.verbose("Downloading dash media ${paramMap["MEDIA_ID"].toString()}", featureKey) val playlistUrl = paramMap["MEDIA_ID"].toString().let { val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) @@ -340,15 +345,67 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" } - provideDownloadManagerClient( - mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", - downloadSource = MediaDownloadSource.PUBLIC_STORY, - mediaAuthor = storyName - ).downloadDashMedia( - playlistUrl, - snapChapterTimestamp, - duration - ) + context.runOnUiThread { + val selectedChapters = mutableListOf() + val chapters = snapChapterList.mapIndexed { index, snapChapter -> + val nextChapter = snapChapterList.getOrNull(index + 1) + val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs) + SnapChapterInfo(snapChapter.startTimeMs, duration) + } + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle("Download dash media") + setMultiChoiceItems( + chapters.map { "Segment ${prettyPrintTime(it.offset)} - ${prettyPrintTime(it.offset + (it.duration ?: 0))}" }.toTypedArray(), + List(chapters.size) { index -> currentChapterIndex == index }.toBooleanArray() + ) { _, which, isChecked -> + if (isChecked) { + selectedChapters.add(which) + } else if (selectedChapters.contains(which)) { + selectedChapters.remove(which) + } + } + setPositiveButton("Download") { dialog, which -> + val groups = mutableListOf>() + var currentGroup = mutableListOf() + var lastChapterIndex = -1 + + //check for consecutive chapters + chapters.filterIndexed { index, _ -> selectedChapters.contains(index) } + .forEachIndexed { index, pair -> + if (lastChapterIndex != -1 && index != lastChapterIndex + 1) { + groups.add(currentGroup) + currentGroup = mutableListOf() + } + currentGroup.add(pair) + lastChapterIndex = index + } + + if (currentGroup.isNotEmpty()) { + groups.add(currentGroup) + } + + groups.forEach { group -> + val firstChapter = group.first() + val lastChapter = group.last() + val duration = if (firstChapter == lastChapter) { + firstChapter.duration + } else { + lastChapter.duration?.let { lastChapter.offset - firstChapter.offset + it } + } + + provideDownloadManagerClient( + mediaIdentifier = "${paramMap["STORY_ID"]}-${firstChapter.offset}-${lastChapter.offset}", + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName + ).downloadDashMedia( + playlistUrl, + firstChapter.offset.plus(100), + duration + ) + } + } + }.show() + } } } From e4c511ca47c2afdc0885dd8c8e073c25edd3c0e1 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 20:43:09 +0200 Subject: [PATCH 019/274] feat: ffmpeg logs --- .../snapenhance/download/DownloadProcessor.kt | 7 +++- .../snapenhance/download/FFMpegProcessor.kt | 33 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index a9a226600..3a66738ba 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -62,7 +62,12 @@ class DownloadProcessor ( remoteSideContext.translation.getCategory("download_processor") } - private val ffmpegProcessor by lazy { FFMpegProcessor(remoteSideContext.config.root.downloader.ffmpegOptions) } + private val ffmpegProcessor by lazy { + FFMpegProcessor( + remoteSideContext.log, + remoteSideContext.config.root.downloader.ffmpegOptions + ) + } private val gson by lazy { GsonBuilder().setPrettyPrinting().create() } private fun fallbackToast(message: Any) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt index 80cabbbbd..f85ed77e0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -2,7 +2,10 @@ package me.rhunk.snapenhance.download import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegSession +import com.arthenica.ffmpegkit.Level import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.LogLevel +import me.rhunk.snapenhance.LogManager import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.core.config.impl.DownloaderConfig import java.io.File @@ -32,8 +35,12 @@ class ArgumentList : LinkedHashMap>() { class FFMpegProcessor( + private val logManager: LogManager, private val ffmpegOptions: DownloaderConfig.FFMpegOptions ) { + companion object { + private const val TAG = "ffmpeg-processor" + } enum class Action { DOWNLOAD_DASH, MERGE_OVERLAY, @@ -66,15 +73,23 @@ class FFMpegProcessor( Logger.directDebug("arguments: $stringBuilder", "FFMpegProcessor") - FFmpegKit.executeAsync(stringBuilder.toString(), { session -> - it.resumeWith( - if (session.returnCode.isValueSuccess) { - Result.success(session) - } else { - Result.failure(Exception(session.output)) - } - ) - }, Executors.newSingleThreadExecutor()) + FFmpegKit.executeAsync(stringBuilder.toString(), + { session -> + it.resumeWith( + if (session.returnCode.isValueSuccess) { + Result.success(session) + } else { + Result.failure(Exception(session.output)) + } + ) + }, logFunction@{ log -> + logManager.internalLog(TAG, when (log.level) { + Level.AV_LOG_ERROR, Level.AV_LOG_FATAL -> LogLevel.ERROR + Level.AV_LOG_WARNING -> LogLevel.WARN + Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE + else -> return@logFunction + }, log.message) + }, { logManager.verbose(it.toString(), "ffmpeg-stats") }, Executors.newSingleThreadExecutor()) } suspend fun execute(args: Request) { From 3cde2aba9ac0aae2889ef77ce7b01ccb63c35837 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Sep 2023 20:54:04 +0200 Subject: [PATCH 020/274] feat(media_downloader): download the whole dash media --- .../ui/manager/sections/downloads/DownloadsSection.kt | 1 + .../features/impl/downloader/MediaDownloader.kt | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt index 1514d8ead..5c1ab34ee 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -229,6 +229,7 @@ class DownloadsSection : Section() { } //open FilledIconButton(onClick = { + if (download.outputFile == null) return@FilledIconButton val fileType = runCatching { context.androidContext.contentResolver.openInputStream(Uri.parse(download.outputFile))?.use { input -> FileType.fromInputStream(input) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index b8233079f..cf0dbdc98 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -364,6 +364,14 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp selectedChapters.remove(which) } } + setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } + setNeutralButton("Download all") { _, _ -> + provideDownloadManagerClient( + mediaIdentifier = paramMap["STORY_ID"].toString(), + downloadSource = MediaDownloadSource.PUBLIC_STORY, + mediaAuthor = storyName + ).downloadDashMedia(playlistUrl, 0, null) + } setPositiveButton("Download") { dialog, which -> val groups = mutableListOf>() var currentGroup = mutableListOf() From b2e9afcb35b38836df9426954d462c90ed6f6c14 Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Fri, 1 Sep 2023 22:45:06 +0200 Subject: [PATCH 021/274] refactor: cpp syntax make isSplitApk nullable --- .../ui/manager/data/InstallationSummary.kt | 2 +- native/.gitignore | 3 +- native/jni/src/library.cpp | 98 +++++++------------ 3 files changed, 38 insertions(+), 65 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt index c8af6d0d3..ca73c5a97 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/data/InstallationSummary.kt @@ -6,7 +6,7 @@ data class SnapchatAppInfo( val version: String, val versionCode: Long, val isLSPatched: Boolean, - val isSplitApk: Boolean, + val isSplitApk: Boolean? ) data class ModInfo( diff --git a/native/.gitignore b/native/.gitignore index 42afabfd2..7607bd2c3 100644 --- a/native/.gitignore +++ b/native/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/.cxx \ No newline at end of file diff --git a/native/jni/src/library.cpp b/native/jni/src/library.cpp index 96de8a501..898effe7d 100644 --- a/native/jni/src/library.cpp +++ b/native/jni/src/library.cpp @@ -14,90 +14,70 @@ #define ARM64 false #endif - static native_config_t *native_config; static JavaVM *java_vm; - +static jmethodID native_lib_on_unary_call_method; +static void *(*unaryCall_original)(void *, const char *, grpc::grpc_byte_buffer **, void *, void *, void *); static auto fstat_original = (int (*)(int, struct stat *)) nullptr; static int fstat_hook(int fd, struct stat *buf) { char name[256]; - memset(name, 0, 256); + memset(name, 0, sizeof(name)); snprintf(name, sizeof(name), "/proc/self/fd/%d", fd); readlink(name, name, sizeof(name)); - auto fileName = std::string(name); + std::string fileName(name); - //prevent blizzardv2 metrics - if (native_config->disable_metrics && - fileName.find("files/blizzardv2/queues") != std::string::npos) { + if (native_config->disable_metrics && fileName.find("files/blizzardv2/queues") != std::string::npos) { unlink(name); return -1; } - //prevent bitmoji to load - if (native_config->disable_bitmoji && - fileName.find("com.snap.file_manager_4_SCContent") != std::string::npos) { + if (native_config->disable_bitmoji && fileName.find("com.snap.file_manager_4_SCContent") != std::string::npos) { return -1; } return fstat_original(fd, buf); } - static jobject native_lib_object; -static jmethodID native_lib_on_unary_call_method; -static auto unaryCall_original = (void *(*)(void *, const char *, grpc::grpc_byte_buffer **, void *, - void *, void *)) nullptr; - -static void * -unaryCall_hook(void *unk1, const char *uri, grpc::grpc_byte_buffer **buffer_ptr, void *unk4, - void *unk5, void *unk6) { - auto slice_buffer = (*buffer_ptr)->slice_buffer; +static void *unaryCall_hook(void *unk1, const char *uri, grpc::grpc_byte_buffer **buffer_ptr, void *unk4, void *unk5, void *unk6) { // request without reference counter can be hooked using xposed ig + auto slice_buffer = (*buffer_ptr)->slice_buffer; if (slice_buffer->ref_counter == 0) { return unaryCall_original(unk1, uri, buffer_ptr, unk4, unk5, unk6); } - auto env = (JNIEnv *) nullptr; - java_vm->GetEnv((void **) &env, JNI_VERSION_1_6); + JNIEnv *env = nullptr; + java_vm->GetEnv((void **)&env, JNI_VERSION_1_6); auto jni_buffer_array = env->NewByteArray(slice_buffer->length); - env->SetByteArrayRegion(jni_buffer_array, 0, slice_buffer->length, - (jbyte *) slice_buffer->data); + env->SetByteArrayRegion(jni_buffer_array, 0, slice_buffer->length, (jbyte *)slice_buffer->data); - auto native_request_data_object = env->CallObjectMethod(native_lib_object, - native_lib_on_unary_call_method, - env->NewStringUTF(uri), - jni_buffer_array); + auto native_request_data_object = env->CallObjectMethod(native_lib_object, native_lib_on_unary_call_method, env->NewStringUTF(uri), jni_buffer_array); if (native_request_data_object != nullptr) { auto native_request_data_class = env->GetObjectClass(native_request_data_object); - auto is_canceled = env->GetBooleanField(native_request_data_object, - env->GetFieldID(native_request_data_class, - "canceled", "Z")); + auto is_canceled = env->GetBooleanField(native_request_data_object, env->GetFieldID(native_request_data_class, "canceled", "Z")); + if (is_canceled) { LOGD("canceled request for %s", uri); return nullptr; } - auto new_buffer = env->GetObjectField(native_request_data_object, - env->GetFieldID(native_request_data_class, "buffer", - "[B")); - auto new_buffer_length = env->GetArrayLength((jbyteArray) new_buffer); - auto new_buffer_data = env->GetByteArrayElements((jbyteArray) new_buffer, nullptr); + auto new_buffer = env->GetObjectField(native_request_data_object, env->GetFieldID(native_request_data_class, "buffer", "[B")); + auto new_buffer_length = env->GetArrayLength((jbyteArray)new_buffer); + auto new_buffer_data = env->GetByteArrayElements((jbyteArray)new_buffer, nullptr); LOGD("rewrote request for %s (length: %d)", uri, new_buffer_length); //we need to allocate a new ref_counter struct and copy the old ref_counter and the new_buffer to it - const static auto ref_counter_struct_size = - (uintptr_t) slice_buffer->data - (uintptr_t) slice_buffer->ref_counter; + const static auto ref_counter_struct_size = (uintptr_t)slice_buffer->data - (uintptr_t)slice_buffer->ref_counter; auto new_ref_counter = malloc(ref_counter_struct_size + new_buffer_length); //copy the old ref_counter and the native_request_data_object memcpy(new_ref_counter, slice_buffer->ref_counter, ref_counter_struct_size); - memcpy((void *) ((uintptr_t) new_ref_counter + ref_counter_struct_size), new_buffer_data, - new_buffer_length); + memcpy((void *)((uintptr_t)new_ref_counter + ref_counter_struct_size), new_buffer_data, new_buffer_length); //free the old ref_counter free(slice_buffer->ref_counter); @@ -105,13 +85,12 @@ unaryCall_hook(void *unk1, const char *uri, grpc::grpc_byte_buffer **buffer_ptr, //update the slice_buffer slice_buffer->ref_counter = new_ref_counter; slice_buffer->length = new_buffer_length; - slice_buffer->data = (uint8_t *) ((uintptr_t) new_ref_counter + ref_counter_struct_size); + slice_buffer->data = (uint8_t *)((uintptr_t)new_ref_counter + ref_counter_struct_size); } return unaryCall_original(unk1, uri, buffer_ptr, unk4, unk5, unk6); } - void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { LOGD("Initializing native"); // config @@ -119,33 +98,32 @@ void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { // native lib object native_lib_object = env->NewGlobalRef(clazz); - native_lib_on_unary_call_method = env->GetMethodID( - env->GetObjectClass(clazz), - "onNativeUnaryCall", - "(Ljava/lang/String;[B)L" BUILD_NAMESPACE "/NativeRequestData;" - ); + native_lib_on_unary_call_method = env->GetMethodID(env->GetObjectClass(clazz), "onNativeUnaryCall", "(Ljava/lang/String;[B)L" BUILD_NAMESPACE "/NativeRequestData;"); // load libclient.so util::load_library(env, classloader, "client"); auto client_module = util::get_module("libclient.so"); + if (client_module.base == 0) { LOGE("libclient not found"); return; } - //client_module.base -= 0x1000; // debugging purposes + + // client_module.base -= 0x1000; + // debugging purposes LOGD("libclient.so base=0x%0lx, size=0x%0lx", client_module.base, client_module.size); // hooks - DobbyHook((void *) DobbySymbolResolver("libc.so", "fstat"), (void *) fstat_hook, - (void **) &fstat_original); + DobbyHook((void *)DobbySymbolResolver("libc.so", "fstat"), (void *)fstat_hook, (void **)&fstat_original); auto unaryCall_func = util::find_signature( client_module.base, client_module.size, ARM64 ? "A8 03 1F F8 C2 00 00 94" : "0A 90 00 F0 3F F9", ARM64 ? -0x48 : -0x38 ); + if (unaryCall_func != 0) { - DobbyHook((void *) unaryCall_func, (void *) unaryCall_hook, (void **) &unaryCall_original); + DobbyHook((void *)unaryCall_func, (void *)unaryCall_hook, (void **)&unaryCall_original); } else { LOGE("can't find unaryCall signature"); } @@ -161,22 +139,16 @@ void JNICALL load_config(JNIEnv *env, jobject _, jobject config_object) { native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics"); } -extern "C" JNIEXPORT jint JNICALL -JNI_OnLoad(JavaVM *vm, void *_) { - java_vm = vm; +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { // register native methods + java_vm = vm; JNIEnv *env = nullptr; - vm->GetEnv((void **) &env, JNI_VERSION_1_6); + vm->GetEnv((void **)&env, JNI_VERSION_1_6); auto methods = std::vector(); - methods.push_back({"init", "(Ljava/lang/ClassLoader;)V", (void *) init}); - methods.push_back({"loadConfig", "(L" BUILD_NAMESPACE "/NativeConfig;)V", - (void *) load_config}); - - env->RegisterNatives( - env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), - methods.data(), - methods.size() - ); + methods.push_back({"init", "(Ljava/lang/ClassLoader;)V", (void *)init}); + methods.push_back({"loadConfig", "(L" BUILD_NAMESPACE "/NativeConfig;)V", (void *)load_config}); + + env->RegisterNatives(env->FindClass(std::string(BUILD_NAMESPACE "/NativeLib").c_str()), methods.data(), methods.size()); return JNI_VERSION_1_6; } From 58f4f51fe69cec02a273dfe082d09eb08372bdce Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sat, 2 Sep 2023 00:23:29 +0200 Subject: [PATCH 022/274] chore: improve source translation --- README.md | 2 +- .../ui/manager/sections/home/HomeSection.kt | 2 +- core/src/main/assets/lang/en_US.json | 132 +++++++++--------- .../core/config/impl/RootConfig.kt | 5 +- 4 files changed, 70 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index e5f20d7c6..5082f60fe 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Although using this in an unrooted enviroment using something like LSPatch shoul UI & Tweaks - Disable Camera - - Immersive Camera Preview (Fix Snapchat's camera bug) + - Immersive Camera Preview (Fix Snapchats camera bug) - Hide certain UI Elements - Show Streak Expiration Info - Disable Snap Splitting diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 329e90ccf..a77164b18 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -363,7 +363,7 @@ class HomeSection : Section() { } Text( - text = "An xposed module that enhances the Snapchat experience", + text = "An Xposed module made to enhance your Snapchat experience", modifier = Modifier.padding(16.dp) ) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index e1d2e7adb..6edf12d12 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -2,15 +2,15 @@ "setup": { "dialogs": { "select_language": "Select Language", - "save_folder": "For downloading snapchat media, you'll need to choose a save location. This can be changed later in the application settings.", - "select_save_folder_button": "Select Save Folder", - "mappings": "To support a wide range of versions, mappings need to be generated for the current snapchat version." + "save_folder": "SnapEnhance requires Storage permissions to download and Save Media from Snapchat.\nPlease choose the location where media should be downloaded to.", + "select_save_folder_button": "Select Folder", + "mappings": "To dynamically support a wide range of Snapchat Versions, mappings are necessary for SnapEnhance to function properly, this should not take more than 5 seconds." }, "mappings": { - "dialog": "To support a wide range of versions, mappings need to be generated for the current snapchat version.", + "dialog": "To dynamically support a wide range of Snapchat Versions, mappings are necessary for SnapEnhance to function properly, this should not take more than 5 seconds.", "generate_button": "Generate", - "generate_failure_no_snapchat": "Snapchat could not be found on your device. Please install Snapchat and try again.", - "generate_failure": "An error occurred while generating mappings. Please try again.", + "generate_failure_no_snapchat": "SnapEnhance was unable to detect Snapchat, please try reinstalling Snapchat.", + "generate_failure": "An error occurred while trying to generate mappings, please try again.", "generate_success": "Mappings generated successfully." } }, @@ -38,7 +38,7 @@ "properties": { "auto_download": { "name": "Auto download", - "description": "Auto download snaps when viewed", + "description": "Automatically download Snaps when viewing them", "options": { "blacklist": "Exclude from Auto Download", "whitelist": "Auto Download" @@ -48,20 +48,20 @@ "name": "Stealth Mode", "description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations", "options": { - "blacklist": "Exclude from stealth mode", + "blacklist": "Exclude from Stealth Mode", "whitelist": "Stealth mode" } }, "auto_save": { "name": "Auto Save", - "description": "Saves chat messages when viewed", + "description": "Saves Chat Messages when viewing them", "options": { - "blacklist": "Exclude from auto save", + "blacklist": "Exclude from Auto save", "whitelist": "Auto save" } }, "hide_chat_feed": { - "name": "Hide from chat feed" + "name": "Hide from Chat feed" } } }, @@ -72,15 +72,15 @@ "refresh_mappings": "Refresh Mappings", "open_map": "Choose location on map", "check_for_updates": "Check for updates", - "export_chat_messages": "Export chat messages" + "export_chat_messages": "Export Chat Messages" }, "features": { "notices": { - "unstable": "This is unstable and may not work as expected", - "may_ban": "This may get you banned\nUse at your own risk", + "unstable": "This feature is unstable and may cause issues", + "may_ban": "This feature may cause bans", "may_break_internal_behavior": "This may break Snapchat internal behavior", - "may_cause_crashes": "This may cause crashes" + "may_cause_crashes": "This may cause instability" }, "properties": { "downloader": { @@ -89,7 +89,7 @@ "properties": { "save_folder": { "name": "Save Folder", - "description": "The directory where all media is saved" + "description": "Select the directory to which all media should be downloaded to" }, "auto_download_sources": { "name": "Auto Download Sources", @@ -101,7 +101,7 @@ }, "path_format": { "name": "Path Format", - "description": "Specify the file path format" + "description": "Specify the File Path Format" }, "allow_duplicate": { "name": "Allow Duplicate", @@ -109,19 +109,19 @@ }, "merge_overlays": { "name": "Merge Overlays", - "description": "Combines the text and the media of a Snap into a single file" + "description": "Combines the Text and the media of a Snap into a single file" }, "force_image_format": { "name": "Force Image Format", - "description": "Forces images to be saved as a specific format" + "description": "Forces images to be saved in a specified Format" }, "force_voice_note_format": { "name": "Force Voice Note Format", - "description": "Forces voice notes to be saved as a specific format" + "description": "Forces Voice Notes to be saved in a specified Format" }, "chat_download_context_menu": { "name": "Chat Download Context Menu", - "description": "Allows to download messages from a conversation by long pressing them" + "description": "Allows you to download media from a conversation by long-pressing them" }, "ffmpeg_options": { "name": "FFmpeg Options", @@ -137,23 +137,23 @@ }, "constant_rate_factor": { "name": "Constant Rate Factor", - "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264 (lower to higher quality)" + "description": "Set the constant rate factor for the video encoder\nFrom 0 to 51 for libx264" }, "video_bitrate": { "name": "Video Bitrate", - "description": "Set the video bitrate (in kbps)" + "description": "Set the video bitrate (kbps)" }, "audio_bitrate": { "name": "Audio Bitrate", - "description": "Set the audio bitrate (in kbps)" + "description": "Set the audio bitrate (kbps)" }, "custom_video_codec": { "name": "Custom Video Codec", - "description": "Set a custom video codec (e.g. libx264)" + "description": "Set a custom Video Codec (e.g. libx264)" }, "custom_audio_codec": { "name": "Custom Audio Codec", - "description": "Set a custom audio codec (e.g. aac)" + "description": "Set a custom Audio Codec (e.g. AAC)" } } }, @@ -169,19 +169,19 @@ "properties": { "enable_app_appearance": { "name": "Enable App Appearance Settings", - "description": "Enables the hidden app appearance settings" + "description": "Enables the hidden App Appearance Setting\nMay not be required on newer Snapchat versions" }, "amoled_dark_mode": { "name": "AMOLED Dark Mode", - "description": "Enables AMOLED dark mode\nMake sure Snapchat's dark mode is enabled" + "description": "Enables AMOLED dark mode\nMake sure Snapchats Dark mode is enabled" }, "map_friend_nametags": { "name": "Enhanced Friend Map Nametags", - "description": "Enhances the nametags of friends on the map" + "description": "Improves the Nametags of friends on the Snapmap" }, "streak_expiration_info": { "name": "Show Streak Expiration Info", - "description": "Shows Streak expiration info next to streaks" + "description": "Shows a Streak Expiration timer next to the Streaks counter" }, "hide_story_sections": { "name": "Hide Story Section", @@ -227,19 +227,19 @@ }, "hide_bitmoji_presence": { "name": "Hide Bitmoji Presence", - "description": "Hides your Bitmoji presence from the chat" + "description": "Prevents your Bitmoji from popping up while in Chat" }, "hide_typing_notifications": { "name": "Hide Typing Notifications", - "description": "Prevents anyone from knowing you're typing in a conversation" + "description": "Prevents anyone from knowing you're typing a message" }, "unlimited_snap_view_time": { "name": "Unlimited Snap View Time", - "description": "Removes the time limit for viewing Snaps" + "description": "Removes the Time Limit for viewing Snaps" }, "disable_replay_in_ff": { "name": "Disable Replay in FF", - "description": "Disables the ability to replay with a long press from the friend feed" + "description": "Disables the ability to replay with a long press from the Friend Feed" }, "prevent_message_sending": { "name": "Prevent Message Sending", @@ -251,33 +251,33 @@ }, "notification_blacklist": { "name": "Notification Blacklist", - "description": "Select the notifications to be blocked" + "description": "Select notifications which should get blocked" }, "message_logger": { "name": "Message Logger", - "description": "Keeps messages when someone deletes them. This only works for messages deleted after enabling this feature" + "description": "Prevents messages from being deleted" }, "auto_save_messages_in_conversations": { "name": "Auto Save Messages", - "description": "Automatically saves messages in conversations" + "description": "Automatically saves every message in conversations" }, "gallery_media_send_override": { "name": "Gallery Media Send Override", - "description": "Convert a gallery media to a specific media type before it's sent" + "description": "Spoofs the media source when sending from the Gallery" }, "message_preview_length": { "name": "Message Preview Length", - "description": "Specify the amount of messages to be previewed" + "description": "Specify the amount of messages to get previewed" } } }, "global": { "name": "Global", - "description": "Tweak Snapchat globally", + "description": "Tweak Snapchat Globally", "properties": { "snapchat_plus": { "name": "Snapchat Plus", - "description": "Enables Snapchat Plus features (some features may not work)" + "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, "auto_updater": { "name": "Auto Updater", @@ -285,15 +285,15 @@ }, "disable_metrics": { "name": "Disable Metrics", - "description": "Disables some analytics data sent to Snapchat" + "description": "Blocks sending specific analytic data to Snapchat" }, "block_ads": { "name": "Block Ads", - "description": "Prevent ads from being displayed" + "description": "Prevents Advertisements from being displayed" }, "disable_video_length_restrictions": { "name": "Disable Video Length Restrictions", - "description": "Prevents Snapchat from blocking videos that are too long" + "description": "Disables Snapchat's maximum video length restriction" }, "disable_google_play_dialogs": { "name": "Disable Google Play Services Dialogs", @@ -301,17 +301,17 @@ }, "force_media_source_quality": { "name": "Force Media Source Quality", - "description": "Overrides the media quality to the highest possible" + "description": "Forces Snapchat's Media Quality to the specified value" }, "disable_snap_splitting": { "name": "Disable Snap Splitting", - "description": "Prevents Snaps from being split into multiple parts. It also convert sent images into videos" + "description": "Prevents Snaps from being split into multiple parts\nPictures you send will turn into videos" } } }, "rules": { "name": "Rules", - "description": "Manage automatic features\nThe social tab lets you assign a rule to an object" + "description": "Manage Automatic Features\nThe social tab lets you assign a rule to an object" }, "camera": { "name": "Camera", @@ -323,11 +323,11 @@ }, "immersive_camera_preview": { "name": "Immersive Preview", - "description": "Stops Snapchat from cropping the camera preview. It might cause flickering on some devices" + "description": "Prevents Snapchat from Cropping the Camera preview\nThis might cause the camera to flicker on some devices" }, "override_preview_resolution": { "name": "Override Preview Resolution", - "description": "Overrides the camera preview resolution" + "description": "Overrides the Camera Preview Resolution" }, "override_picture_resolution": { "name": "Override Picture Resolution", @@ -349,11 +349,11 @@ "properties": { "interval": { "name": "Interval", - "description": "The interval between each reminder (in hours)" + "description": "The interval between each reminder (hours)" }, "remaining_hours": { - "name": "Remaining Hours", - "description": "The remaining hours before the notification is shown" + "name": "Remaining Time", + "description": "The remaining amount of time before the notification is shown" }, "group_notifications": { "name": "Group Notifications", @@ -367,21 +367,21 @@ "properties": { "native_hooks": { "name": "Native Hooks", - "description": "Unsafe features that hook into Snapchat's native code", + "description": "Unsafe Features that hook into Snapchat's native code", "properties": { "disable_bitmoji": { "name": "Disable Bitmoji", - "description": "Disables friends profile bitmoji" + "description": "Disables Friends Profile Bitmoji" }, "fix_gallery_media_override": { "name": "Fix Gallery Media Override", - "description": "Fixes various issues with the Gallery Media Send Override feature (e.g. save snaps in chat)" + "description": "Fixes various issues with the Gallery Media Send Override feature (e.g. Save Snaps in chat)" } } }, "spoof": { "name": "Spoof", - "description": "Spoof your information", + "description": "Spoof various information about you", "properties": { "location": { "name": "Location", @@ -389,7 +389,7 @@ }, "device": { "name": "Device", - "description": "Spoof your device" + "description": "Spoof your device information" } } }, @@ -403,7 +403,7 @@ }, "infinite_story_boost": { "name": "Infinite Story Boost", - "description": "Bypass the story boost limit delay" + "description": "Bypass the Story Boost Limit delay" }, "meo_passcode_bypass": { "name": "My Eyes Only Passcode Bypass", @@ -411,15 +411,15 @@ }, "unlimited_multi_snap": { "name": "Unlimited Multi Snap", - "description": "Allows you to take an unlimited amount of multi snaps" + "description": "Allows you to take an Unlimited Amount of Multi Snaps" }, "no_friend_score_delay": { "name": "No Friend Score Delay", - "description": "Removes the delay when viewing a friends score" + "description": "Removes the delay when viewing a Friends Score" }, "add_friend_source_spoof": { "name": "Add Friend Source Spoof", - "description": "Spoofs the source of a friend request" + "description": "Spoofs the source of a Friend Request" } } } @@ -427,7 +427,7 @@ "options": { "better_notifications": { "chat": "Show chat messages", - "snap": "Show medias", + "snap": "Show media", "reply_button": "Add reply button", "download_button": "Add download button", "group": "Group notifications" @@ -494,7 +494,7 @@ }, "auto_updater": { "DISABLED": "Disabled", - "EVERY_LAUNCH": "Every Launch", + "EVERY_LAUNCH": "On Every Launch", "DAILY": "Daily", "WEEKLY": "Weekly" }, @@ -645,8 +645,8 @@ "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}" + "failed_processing_toast": "Failed processing {error}", + "failed_gallery_toast": "Failed saving to gallery {error}" }, "config_activity": { "title": "SnapEnhance Settings", @@ -658,6 +658,6 @@ }, "streaks_reminder": { "notification_title": "Streaks", - "notification_text": "You will lose streaks with {friend} in {hoursLeft} hours" + "notification_text": "You will lose your Streak with {friend} in {hoursLeft} hours" } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt index f35cfe16b..056dc3578 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt @@ -11,7 +11,6 @@ class RootConfig : ConfigContainer() { val rules = container("rules", Rules()) { icon = "Rule" } val camera = container("camera", Camera()) { icon = "Camera"} val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } - val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices( - FeatureNotice.UNSTABLE, FeatureNotice.MAY_CAUSE_CRASHES - ) } + val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices(FeatureNotice.UNSTABLE) + } } \ No newline at end of file From 33131728cadbb3e203d17ddec4bf2814b714137c Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 2 Sep 2023 12:32:24 +0200 Subject: [PATCH 023/274] feat(export_chat_messages): ability to select the amount of message --- core/src/main/assets/lang/en_US.json | 1 + .../action/impl/ExportChatMessages.kt | 109 +++++++----- .../util/export/MessageExporter.kt | 162 ++++++++++-------- .../util/snap/MediaDownloaderHelper.kt | 7 +- 4 files changed, 162 insertions(+), 117 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 6edf12d12..3ffe14c5f 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -587,6 +587,7 @@ "chat_export": { "select_export_format": "Select the Export Format", "select_media_type": "Select Media Types to export", + "select_amount_of_messages": "Select the amount of messages to export (leave empty for all)", "select_conversation": "Select a Conversation to export", "dialog_negative_button": "Cancel", "dialog_neutral_button": "Export All", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt index a80c6623c..d9cfdf19d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -2,12 +2,11 @@ package me.rhunk.snapenhance.action.impl import android.app.AlertDialog import android.content.DialogInterface -import android.content.Intent -import android.net.Uri import android.os.Environment -import kotlinx.coroutines.DelicateCoroutinesApi +import android.text.InputType +import android.widget.EditText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -24,8 +23,8 @@ import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.export.ExportFormat import me.rhunk.snapenhance.util.export.MessageExporter import java.io.File +import kotlin.math.absoluteValue -@OptIn(DelicateCoroutinesApi::class) class ExportChatMessages : AbstractAction() { private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } @@ -45,21 +44,30 @@ class ExportChatMessages : AbstractAction() { context.feature(Messaging::class).conversationManager } + private val coroutineScope = CoroutineScope(Dispatchers.Default) + private val dialogLogs = mutableListOf() private var currentActionDialog: AlertDialog? = null private var exportType: ExportFormat? = null private var mediaToDownload: List? = null + private var amountOfMessages: Int? = null private fun logDialog(message: String) { context.runOnUiThread { - if (dialogLogs.size > 15) dialogLogs.removeAt(0) + if (dialogLogs.size > 10) dialogLogs.removeAt(0) dialogLogs.add(message) - context.log.debug("dialog: $message") + context.log.debug("dialog: $message", "ExportChatMessages") currentActionDialog!!.setMessage(dialogLogs.joinToString("\n")) } } + private fun setStatus(message: String) { + context.runOnUiThread { + currentActionDialog!!.setTitle(message) + } + } + private suspend fun askExportType() = suspendCancellableCoroutine { cont -> context.runOnUiThread { ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) @@ -74,6 +82,26 @@ class ExportChatMessages : AbstractAction() { } } + private suspend fun askAmountOfMessages() = suspendCancellableCoroutine { cont -> + coroutineScope.launch(Dispatchers.Main) { + val input = EditText(context.mainActivity) + input.inputType = InputType.TYPE_CLASS_NUMBER + input.setSingleLine() + input.maxLines = 1 + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["chat_export.select_amount_of_messages"]) + .setView(input) + .setPositiveButton(context.translation["button.ok"]) { _, _ -> + cont.resumeWith(Result.success(input.text.takeIf { it.isNotEmpty() }?.toString()?.toIntOrNull()?.absoluteValue)) + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .show() + } + } + private suspend fun askMediaToDownload() = suspendCancellableCoroutine { cont -> context.runOnUiThread { val mediasToDownload = mutableListOf() @@ -96,7 +124,7 @@ class ExportChatMessages : AbstractAction() { .setOnCancelListener { cont.resumeWith(Result.success(null)) } - .setPositiveButton("OK") { _, _ -> + .setPositiveButton(context.translation["button.ok"]) { _, _ -> cont.resumeWith(Result.success(mediasToDownload)) } .show() @@ -104,11 +132,12 @@ class ExportChatMessages : AbstractAction() { } override fun run() { - GlobalScope.launch(Dispatchers.Main) { + coroutineScope.launch(Dispatchers.Main) { exportType = askExportType() ?: return@launch mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null + amountOfMessages = askAmountOfMessages() - val friendFeedEntries = context.database.getFeedEntries(20) + val friendFeedEntries = context.database.getFeedEntries(500) val selectedConversations = mutableListOf() ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) @@ -177,7 +206,7 @@ class ExportChatMessages : AbstractAction() { conversationManagerInstance, SnapUUID.fromString(conversationId).instanceNonNull(), lastMessageId, - 100, + 500, callback ) } @@ -200,10 +229,17 @@ class ExportChatMessages : AbstractAction() { while (true) { val messages = fetchMessagesPaginated(conversationId, lastMessageId) if (messages.isEmpty()) break + + if (amountOfMessages != null && messages.size + foundMessages.size >= amountOfMessages!!) { + foundMessages.addAll(messages.take(amountOfMessages!! - foundMessages.size)) + break + } + foundMessages.addAll(messages) messages.firstOrNull()?.let { lastMessageId = it.messageDescriptor.messageId } + setStatus("Exporting (${foundMessages.size} / ${foundMessages.firstOrNull()?.orderKey})") } val outputFile = File( @@ -212,33 +248,26 @@ class ExportChatMessages : AbstractAction() { ).also { it.parentFile?.mkdirs() } logDialog(context.translation["chat_export.writing_output"]) - MessageExporter( - context = context, - friendFeedEntry = friendFeedEntry, - outputFile = outputFile, - mediaToDownload = mediaToDownload, - printLog = ::logDialog - ).also { - runCatching { - it.readMessages(foundMessages) - }.onFailure { - logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) - context.log.error("Failed to read messages", it) - return - } - }.exportTo(exportType!!) + + runCatching { + MessageExporter( + context = context, + friendFeedEntry = friendFeedEntry, + outputFile = outputFile, + mediaToDownload = mediaToDownload, + printLog = ::logDialog + ).apply { readMessages(foundMessages) }.exportTo(exportType!!) + }.onFailure { + logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) + context.log.error("Failed to export conversation $conversationName", it) + return + } dialogLogs.clear() logDialog("\n" + context.translation.format("chat_export.exported_to", "path" to outputFile.absolutePath.toString() ) + "\n") - currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, "Open") { _, _ -> - val intent = Intent(Intent.ACTION_VIEW) - intent.setDataAndType(Uri.fromFile(outputFile.parentFile), "resource/folder") - context.mainActivity!!.startActivity(intent) - } - runCatching { conversationAction(false, conversationId, null) } @@ -252,19 +281,13 @@ class ExportChatMessages : AbstractAction() { .setTitle(context.translation["chat_export.exporting_chats"]) .setCancelable(false) .setMessage("") - .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> - jobs.forEach { it.cancel() } - dialog.dismiss() - } .create() val conversationSize = context.translation.format("chat_export.processing_chats", "amount" to conversations.size.toString()) logDialog(conversationSize) - currentActionDialog!!.show() - - GlobalScope.launch(Dispatchers.Default) { + coroutineScope.launch { conversations.forEach { conversation -> launch { runCatching { @@ -278,6 +301,14 @@ class ExportChatMessages : AbstractAction() { } jobs.joinAll() logDialog(context.translation["chat_export.finished"]) + }.also { + currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> + it.cancel() + jobs.forEach { it.cancel() } + dialog.dismiss() + } } + + currentActionDialog!!.show() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt index 7c4fe7fd4..e5b148165 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt @@ -6,8 +6,6 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import de.robv.android.xposed.XposedHelpers import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.core.BuildConfig @@ -30,6 +28,8 @@ import java.util.Base64 import java.util.Collections import java.util.Date import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.zip.Deflater import java.util.zip.DeflaterInputStream import java.util.zip.ZipFile @@ -98,14 +98,23 @@ class MessageExporter( suspend fun exportHtml(output: OutputStream) { val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } val mediaFiles = Collections.synchronizedMap(mutableMapOf>()) - - printLog("found ${messages.size} messages") + val threadPool = Executors.newFixedThreadPool(15) withContext(Dispatchers.IO) { + var processCount = 0 + + fun updateProgress(type: String) { + val total = messages.filter { + mediaToDownload?.contains(it.messageContent.contentType) ?: false + }.size + processCount++ + printLog("$type $processCount/$total") + } + messages.filter { mediaToDownload?.contains(it.messageContent.contentType) ?: false - }.map { message -> - async { + }.forEach { message -> + threadPool.execute { val remoteMediaReferences by lazy { val serializedMessageContent = context.gson.toJsonTree(message.messageContent.instanceNonNull()).asJsonObject serializedMessageContent["mRemoteMediaReferences"] @@ -121,8 +130,6 @@ class MessageExporter( EncryptionHelper.decryptInputStream(it, message.messageContent.contentType!!, ProtoReader(message.messageContent.content), isArroyo = false) } - printLog("downloaded media ${message.orderKey}") - downloadedMedia.forEach { (type, mediaData) -> val fileType = FileType.fromByteArray(mediaData) val fileName = "${type}_${kotlin.io.encoding.Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" @@ -134,6 +141,7 @@ class MessageExporter( } mediaFiles[fileName] = fileType to mediaFile + updateProgress("downloaded") } }.onFailure { printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}") @@ -141,64 +149,67 @@ class MessageExporter( } } } - }.awaitAll() - } + } - printLog("writing downloaded medias...") - - //write the head of the html file - output.write(""" - - - - - - - - - """.trimIndent().toByteArray()) - - output.write("\n".toByteArray()) - - mediaFiles.forEach { (key, filePair) -> - printLog("writing $key...") - output.write("
\n".toByteArray()) - output.flush() - } - printLog("writing json conversation data...") - - //write the json file - output.write("\n".toByteArray()) - - printLog("writing template...") - - runCatching { - ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> - //export rawinflate.js - apkFile.getEntry("assets/web/rawinflate.js").let { entry -> - output.write("\n".toByteArray()) - } + threadPool.shutdown() + threadPool.awaitTermination(30, TimeUnit.DAYS) + processCount = 0 + + printLog("writing downloaded medias...") + + //write the head of the html file + output.write(""" + + + + + + + + + """.trimIndent().toByteArray()) + + output.write("\n".toByteArray()) + + mediaFiles.forEach { (key, filePair) -> + output.write("
\n".toByteArray()) + output.flush() + updateProgress("wrote") + } + printLog("writing json conversation data...") + + //write the json file + output.write("\n".toByteArray()) + + printLog("writing template...") + + runCatching { + ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> + //export rawinflate.js + apkFile.getEntry("assets/web/rawinflate.js").let { entry -> + output.write("\n".toByteArray()) + } - //export avenir next font - apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> - val encodedFontData = kotlin.io.encoding.Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) - output.write(""" + //export avenir next font + apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> + val encodedFontData = kotlin.io.encoding.Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + output.write(""" """.trimIndent().toByteArray()) - } + } - apkFile.getEntry("assets/web/export_template.html").let { entry -> - apkFile.getInputStream(entry).copyTo(output) - } + apkFile.getEntry("assets/web/export_template.html").let { entry -> + apkFile.getInputStream(entry).copyTo(output) + } - apkFile.close() + apkFile.close() + } + }.onFailure { + throw Throwable("Failed to read template from apk", it) } - }.onFailure { - printLog("failed to read template from apk") - context.log.error("failed to read template from apk", it) - } - output.write("".toByteArray()) - output.close() - printLog("done") + output.write("".toByteArray()) + output.close() + } } private fun exportJson(output: OutputStream) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt index f9c366c25..d55d5dec5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -36,8 +36,11 @@ object MediaDownloaderHelper { } } - fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map { - val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") + fun downloadMediaFromReference( + mediaReference: ByteArray, + decryptionCallback: (InputStream) -> InputStream, + ): Map { + val 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) val isZipFile = fileType == FileType.ZIP From 3ba4e573adf9e775816b9b115fd966f5a1ff6377 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 2 Sep 2023 12:57:38 +0200 Subject: [PATCH 024/274] feat: 2d bitmoji selfie --- core/src/main/assets/lang/en_US.json | 4 ++++ .../core/config/impl/UserInterfaceTweaks.kt | 1 + .../features/impl/tweaks/OldBitmojiSelfie.kt | 22 +++++++++++++++++++ .../manager/impl/FeatureManager.kt | 2 ++ .../snapenhance/util/snap/BitmojiSelfie.kt | 12 +++++----- 5 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 3ffe14c5f..eeea7478c 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -191,6 +191,10 @@ "name": "Hide UI Components", "description": "Select which UI components to hide" }, + "2d_bitmoji_selfie": { + "name": "2D Bitmoji Selfie", + "description": "Brings back the 2D selfie from older Snapchat versions\nYou need to clean the Snapchat cache from debug for this to take effect" + }, "disable_spotlight": { "name": "Disable Spotlight", "description": "Disables the Spotlight page" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt index 6a3cb91bb..42a489001 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt @@ -24,6 +24,7 @@ class UserInterfaceTweaks : ConfigContainer() { "hide_live_location_share_button", "hide_call_buttons" ) + val ddBitmojiSelfie = boolean("2d_bitmoji_selfie") val disableSpotlight = boolean("disable_spotlight") val startupTab = unique("startup_tab", "ngs_map_icon_container", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt new file mode 100644 index 000000000..b860c5754 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.features.impl.tweaks + +import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.util.snap.BitmojiSelfie + +class OldBitmojiSelfie : Feature("OldBitmojiSelfie", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { + val urlPrefixes = arrayOf("https://images.bitmoji.com/3d/render/", "https://cf-st.sc-cdn.net/3d/render/") + val state by context.config.userInterface.ddBitmojiSelfie + + context.event.subscribe(NetworkApiRequestEvent::class, { state }) { event -> + if (urlPrefixes.firstOrNull { event.url.startsWith(it) } == null) return@subscribe + val bitmojiURI = event.url.substringAfterLast("/") + event.url = + BitmojiSelfie.BitmojiSelfieType.STANDARD.prefixUrl + + bitmojiURI + + (bitmojiURI.takeIf { !it.contains("?") }?.let { "?" } ?: "&") + "transparent=1" + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index 018062a29..fd7ef02e0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -30,6 +30,7 @@ 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.OldBitmojiSelfie import me.rhunk.snapenhance.features.impl.tweaks.SendOverride import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime @@ -95,6 +96,7 @@ class FeatureManager(private val context: ModContext) : Manager { register(ProfilePictureDownloader::class) register(AddFriendSourceSpoof::class) register(DisableReplayInFF::class) + register(OldBitmojiSelfie::class) initializeFeatures() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt index 7a0db782f..71981a488 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt @@ -1,9 +1,11 @@ package me.rhunk.snapenhance.util.snap object BitmojiSelfie { - enum class BitmojiSelfieType { - STANDARD, - THREE_D + enum class BitmojiSelfieType( + val prefixUrl: String, + ) { + STANDARD("https://sdk.bitmoji.com/render/panel/"), + THREE_D("https://images.bitmoji.com/3d/render/") } fun getBitmojiSelfie(selfieId: String?, avatarId: String?, type: BitmojiSelfieType): String? { @@ -11,8 +13,8 @@ object BitmojiSelfie { return null } return when (type) { - BitmojiSelfieType.STANDARD -> "https://sdk.bitmoji.com/render/panel/$selfieId-$avatarId-v1.webp?transparent=1" - BitmojiSelfieType.THREE_D -> "https://images.bitmoji.com/3d/render/$selfieId-$avatarId-v1.webp?trim=circle" + BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1" + BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle" } } } \ No newline at end of file From 3656024c986c76b7ab26c19e4f515ff1c70ea167 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 2 Sep 2023 13:24:18 +0200 Subject: [PATCH 025/274] fix(auto_save): prevent saving outside a conversation --- .../snapenhance/data/wrapper/impl/SnapUUID.kt | 6 +++- .../features/impl/tweaks/AutoSave.kt | 32 ++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt index 2ec1643b5..d3c381ca5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt @@ -9,7 +9,7 @@ import java.util.UUID class SnapUUID(obj: Any?) : AbstractWrapper(obj) { private val uuidString by lazy { toUUID().toString() } - val bytes: ByteArray get() = instanceNonNull().getObjectField("mId") as ByteArray + private val bytes: ByteArray get() = instanceNonNull().getObjectField("mId") as ByteArray private fun toUUID(): UUID { val buffer = ByteBuffer.wrap(bytes) @@ -20,6 +20,10 @@ class SnapUUID(obj: Any?) : AbstractWrapper(obj) { return uuidString } + override fun equals(other: Any?): Boolean { + return other is SnapUUID && other.uuidString == uuidString + } + companion object { fun fromString(uuid: String): SnapUUID { return fromUUID(UUID.fromString(uuid)) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt index 66b3ed4c9..8ea55d793 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -67,15 +67,15 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, return autoSaveFilter.any { it == contentType } } - private fun canSave(): Boolean { - if (autoSaveFilter.isEmpty()) return false - - with(context.feature(Messaging::class)) { - if (openedConversationUUID == null) return@canSave false - val conversation = openedConversationUUID.toString() - if (context.feature(StealthMode::class).canUseRule(conversation)) return@canSave false - if (!canUseRule(conversation)) return@canSave false - } + private fun canSaveInConversation(targetConversationId: String): Boolean { + val messaging = context.feature(Messaging::class) + val openedConversationId = messaging.openedConversationUUID?.toString() ?: return false + + if (openedConversationId != targetConversationId) return false + + if (context.feature(StealthMode::class).canUseRule(openedConversationId)) return false + if (!canUseRule(openedConversationId)) return false + return true } @@ -85,9 +85,11 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"), "onFetchConversationWithMessagesComplete", HookStage.BEFORE, - { canSave() } + { autoSaveFilter.isNotEmpty() } ) { param -> val conversationId = SnapUUID(param.arg(0).getObjectField("mConversationId")!!) + if (!canSaveInConversation(conversationId.toString())) return@hook + val messages = param.arg>(1).map { Message(it) } messages.forEach { if (!canSaveMessage(it)) return@forEach @@ -102,11 +104,12 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, context.mappings.getMappedClass("callbacks", "FetchMessageCallback"), "onFetchMessageComplete", HookStage.BEFORE, - { canSave() } + { autoSaveFilter.isNotEmpty() } ) { param -> val message = Message(param.arg(0)) - if (!canSaveMessage(message)) return@hook val conversationId = message.messageDescriptor.conversationId + if (!canSaveInConversation(conversationId.toString())) return@hook + if (!canSaveMessage(message)) return@hook asyncSaveExecutorService.submit { saveMessage(conversationId, message) @@ -117,20 +120,19 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, context.mappings.getMappedClass("callbacks", "SendMessageCallback"), "onSuccess", HookStage.BEFORE, - { canSave() } + { autoSaveFilter.isNotEmpty() } ) { val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() runCatching { fetchConversationWithMessagesPaginatedMethod.invoke( messaging.conversationManager, messaging.openedConversationUUID!!.instanceNonNull(), Long.MAX_VALUE, - 3, + 10, callback ) }.onFailure { Logger.xposedLog("failed to save message", it) } } - } } \ No newline at end of file From 58a13fe5be925e7d92526bc68683f0ded58c4126 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 2 Sep 2023 13:43:47 +0200 Subject: [PATCH 026/274] fix(ui/amoled_dark_mode): opera context menu download button --- .../me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt | 12 ++++++++---- .../ui/menu/impl/OperaContextActionMenu.kt | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt index 4de45c21f..4bda50f05 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt @@ -36,7 +36,7 @@ object ViewAppearanceHelper { } @SuppressLint("DiscouragedApi") - fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false) { + fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { val resources = component.context.resources if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { with(component.context.theme) { @@ -66,9 +66,13 @@ object ViewAppearanceHelper { 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 (isAmoled) { + background = StateListDrawable().apply { + addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, hasRadius)) + addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, hasRadius)) + } + } else { + setBackgroundColor(0x0) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt index e0cc24827..815c52ad7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt @@ -71,7 +71,7 @@ class OperaContextActionMenu : AbstractMenu() { val button = Button(childView.getContext()) button.text = context.translation["opera_context_menu.download"] button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } - applyTheme(button) + applyTheme(button, isAmoled = false) linearLayout.addView(button) (childView as ViewGroup).addView(linearLayout, 0) } catch (e: Throwable) { From e587f4700a491aff805dd5d35e9ebf5630d80f4b Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sat, 2 Sep 2023 19:53:37 +0200 Subject: [PATCH 027/274] chore: fine tuning to style * reduced notices * rewrote some strings --- .../sections/features/FeaturesSection.kt | 10 ++++++++-- core/src/main/assets/lang/en_US.json | 17 ++++++++--------- .../snapenhance/core/config/ConfigObjects.kt | 6 ++---- .../snapenhance/core/config/impl/Camera.kt | 4 ++-- .../core/config/impl/DownloaderConfig.kt | 2 +- .../core/config/impl/Experimental.kt | 4 ++-- .../snapenhance/core/config/impl/Global.kt | 6 +++--- .../core/config/impl/MessagingTweaks.kt | 2 +- .../core/config/impl/UserInterfaceTweaks.kt | 2 +- 9 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt index 7ede6f903..f4d81e0f9 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -76,6 +76,7 @@ import kotlinx.coroutines.launch import me.rhunk.snapenhance.core.config.ConfigContainer import me.rhunk.snapenhance.core.config.ConfigFlag import me.rhunk.snapenhance.core.config.DataProcessors +import me.rhunk.snapenhance.core.config.FeatureNotice import me.rhunk.snapenhance.core.config.PropertyKey import me.rhunk.snapenhance.core.config.PropertyPair import me.rhunk.snapenhance.core.config.PropertyValue @@ -301,6 +302,12 @@ class FeaturesSection : Section() { @Composable private fun PropertyCard(property: PropertyPair<*>) { var clickCallback by remember { mutableStateOf(null) } + val noticeColorMap = mapOf( + FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87), + FeatureNotice.BAN_RISK.key to Color(0xFFFF8585), + FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87) + ) + Card( modifier = Modifier .fillMaxWidth() @@ -356,13 +363,12 @@ class FeaturesSection : Section() { }.forEach { Text( text = context.translation["features.notices.${it.key}"], - color = Color.Yellow, + color = noticeColorMap[it.key] ?: Color(0xFFFFFB87), fontSize = 12.sp, lineHeight = 15.sp ) } } - Row( modifier = Modifier .align(Alignment.CenterVertically) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index eeea7478c..89acfb7a3 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -77,15 +77,14 @@ "features": { "notices": { - "unstable": "This feature is unstable and may cause issues", - "may_ban": "This feature may cause bans", - "may_break_internal_behavior": "This may break Snapchat internal behavior", - "may_cause_crashes": "This may cause instability" + "unstable": "\u26A0 Unstable", + "ban_risk": "\u26A0 This feature may cause bans", + "internal_behavior": "\u26A0 This may break Snapchat internal behavior" }, "properties": { "downloader": { "name": "Downloader", - "description": "Download Snaps and Stories", + "description": "Download Snapchat Media", "properties": { "save_folder": { "name": "Save Folder", @@ -193,7 +192,7 @@ }, "2d_bitmoji_selfie": { "name": "2D Bitmoji Selfie", - "description": "Brings back the 2D selfie from older Snapchat versions\nYou need to clean the Snapchat cache from debug for this to take effect" + "description": "Brings back the 2D Bitmoji selfies from older Snapchat versions\nYou may need to clean the Snapchat cache for this to take effect" }, "disable_spotlight": { "name": "Disable Spotlight", @@ -277,7 +276,7 @@ }, "global": { "name": "Global", - "description": "Tweak Snapchat Globally", + "description": "Tweak Global Snapchat Settings", "properties": { "snapchat_plus": { "name": "Snapchat Plus", @@ -315,7 +314,7 @@ }, "rules": { "name": "Rules", - "description": "Manage Automatic Features\nThe social tab lets you assign a rule to an object" + "description": "Manage Automatic Features for individual people" }, "camera": { "name": "Camera", @@ -349,7 +348,7 @@ }, "streaks_reminder": { "name": "Streaks Reminder", - "description": "Reminds you to keep your streaks", + "description": "Periodically notifies you about your Streaks", "properties": { "interval": { "name": "Interval", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt index a8e24e05c..f82768e3a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt @@ -3,7 +3,6 @@ package me.rhunk.snapenhance.core.config import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import kotlin.reflect.KProperty - data class PropertyPair( val key: PropertyKey, val value: PropertyValue<*> @@ -16,9 +15,8 @@ enum class FeatureNotice( val key: String ) { UNSTABLE(0b0001, "unstable"), - MAY_BAN(0b0010, "may_ban"), - MAY_BREAK_INTERNAL_BEHAVIOR(0b0100, "may_break_internal_behavior"), - MAY_CAUSE_CRASHES(0b1000, "may_cause_crashes"); + BAN_RISK(0b0010, "ban_risk"), + INTERNAL_BEHAVIOR(0b0100, "internal_behavior") } enum class ConfigFlag( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt index 05e268d7f..51dc15e29 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Camera.kt @@ -7,13 +7,13 @@ import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks class Camera : ConfigContainer() { val disable = boolean("disable_camera") - val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } + val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE) } val overridePreviewResolution = unique("override_preview_resolution", *CameraTweaks.resolutions.toTypedArray()) { addFlags(ConfigFlag.NO_TRANSLATE) } val overridePictureResolution = unique("override_picture_resolution", *CameraTweaks.resolutions.toTypedArray()) { addFlags(ConfigFlag.NO_TRANSLATE) } val customFrameRate = unique("custom_frame_rate", "5", "10", "20", "25", "30", "48", "60", "90", "120" - ) { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR); addFlags(ConfigFlag.NO_TRANSLATE) } + ) { addNotices(FeatureNotice.UNSTABLE); addFlags(ConfigFlag.NO_TRANSLATE) } val forceCameraSourceEncoding = boolean("force_camera_source_encoding") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt index accfe4ae1..47202fd1e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -34,7 +34,7 @@ class DownloaderConfig : ConfigContainer() { "append_date_time", ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) } val allowDuplicate = boolean("allow_duplicate") - val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } + val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.UNSTABLE) } val forceImageFormat = unique("force_image_format", "jpg", "png", "webp") { addFlags(ConfigFlag.NO_TRANSLATE) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt index 01360013a..247841921 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt @@ -10,7 +10,7 @@ class Experimental : ConfigContainer() { val appLockOnResume = boolean("app_lock_on_resume") val infiniteStoryBoost = boolean("infinite_story_boost") val meoPasscodeBypass = boolean("meo_passcode_bypass") - val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.MAY_BAN)} + val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.BAN_RISK)} val noFriendScoreDelay = boolean("no_friend_score_delay") val addFriendSourceSpoof = unique("add_friend_source_spoof", "added_by_username", @@ -18,5 +18,5 @@ class Experimental : ConfigContainer() { "added_by_group_chat", "added_by_qr_code", "added_by_community", - ) { addNotices(FeatureNotice.MAY_BAN) } + ) { addNotices(FeatureNotice.BAN_RISK) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt index 2fbce0588..7fe0ac2c8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Global.kt @@ -4,11 +4,11 @@ import me.rhunk.snapenhance.core.config.ConfigContainer import me.rhunk.snapenhance.core.config.FeatureNotice class Global : ConfigContainer() { - val snapchatPlus = boolean("snapchat_plus") { addNotices(FeatureNotice.MAY_BAN) } + val snapchatPlus = boolean("snapchat_plus") { addNotices(FeatureNotice.BAN_RISK) } val disableMetrics = boolean("disable_metrics") val blockAds = boolean("block_ads") - val disableVideoLengthRestrictions = boolean("disable_video_length_restrictions") { addNotices(FeatureNotice.MAY_BAN) } + val disableVideoLengthRestrictions = boolean("disable_video_length_restrictions") { addNotices(FeatureNotice.BAN_RISK) } val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") val forceMediaSourceQuality = boolean("force_media_source_quality") - val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR) } + val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.INTERNAL_BEHAVIOR) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt index e1ab006c0..3928fb847 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/MessagingTweaks.kt @@ -24,7 +24,7 @@ class MessagingTweaks : ConfigContainer() { val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" } - val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } + val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.UNSTABLE) } val galleryMediaSendOverride = boolean("gallery_media_send_override") val messagePreviewLength = integer("message_preview_length", defaultValue = 20) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt index 42a489001..e8dcdf06e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt @@ -33,6 +33,6 @@ class UserInterfaceTweaks : ConfigContainer() { "ngs_community_icon_container", "ngs_spotlight_icon_container", "ngs_search_icon_container" - ) { addNotices(FeatureNotice.MAY_BREAK_INTERNAL_BEHAVIOR) } + ) { addNotices(FeatureNotice.INTERNAL_BEHAVIOR) } val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") { addNotices(FeatureNotice.UNSTABLE) } } From 77584d67e21b7b3801017a5220a832be7abb2730 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Sep 2023 00:11:11 +0200 Subject: [PATCH 028/274] chore(lang): section translation - profile picture downloader config --- .../ui/manager/sections/NotImplemented.kt | 10 ++- .../sections/downloads/DownloadsSection.kt | 8 ++- .../ui/manager/sections/home/HomeSection.kt | 8 ++- .../sections/social/AddFriendDialog.kt | 13 ++-- .../manager/sections/social/ScopeContent.kt | 21 +++--- .../manager/sections/social/SocialSection.kt | 2 +- .../setup/screens/impl/PermissionsScreen.kt | 70 ++++++++++++------- .../rhunk/snapenhance/ui/util/AlertDialogs.kt | 4 +- core/src/main/assets/lang/en_US.json | 64 +++++++++++++---- .../core/config/impl/DownloaderConfig.kt | 1 + .../core/config/impl/RootConfig.kt | 3 +- .../downloader/ProfilePictureDownloader.kt | 14 ++-- 12 files changed, 150 insertions(+), 68 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt index 83bceda9c..0cd63d59b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/NotImplemented.kt @@ -1,14 +1,22 @@ package me.rhunk.snapenhance.ui.manager.sections +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import me.rhunk.snapenhance.ui.manager.Section class NotImplemented : Section() { @Composable override fun Content() { - Column { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text(text = "Not implemented yet!") } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt index 5c1ab34ee..6590f35ae 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/downloads/DownloadsSection.kt @@ -269,9 +269,13 @@ class DownloadsSection : Section() { item { Spacer(Modifier.height(20.dp)) if (loadedDownloads.value.isEmpty()) { - Text(text = "(empty)", fontSize = 20.sp, modifier = Modifier + Text( + text = context.translation["manager.sections.downloads.empty_download_list"], + fontSize = 20.sp, + modifier = Modifier .fillMaxWidth() - .padding(10.dp), textAlign = TextAlign.Center) + .padding(10.dp), textAlign = TextAlign.Center + ) } LaunchedEffect(Unit) { val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index a77164b18..5ec1f168a 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -250,7 +250,9 @@ class HomeSection : Section() { navController.navigate(LOGS_SECTION_ROUTE) showDropDown = false }, text = { - Text(text = "Clear logs") + Text( + text = context.translation["manager.sections.home.logs.clear_logs_button"] + ) }) DropdownMenuItem(onClick = { @@ -267,7 +269,9 @@ class HomeSection : Section() { } showDropDown = false }, text = { - Text(text = "Export logs") + Text( + text = context.translation["manager.sections.home.logs.export_logs_button"] + ) }) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt index 37367aaa9..845444f61 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt @@ -53,6 +53,9 @@ class AddFriendDialog( private val context: RemoteSideContext, private val section: SocialSection, ) { + + private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} + @Composable private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { var currentState by remember { mutableStateOf(getCurrentState()) } @@ -95,7 +98,7 @@ class AddFriendDialog( .padding(10.dp), ) { Text( - text = "Add Friend or Group", + text = translation["title"], fontSize = 23.sp, fontWeight = FontWeight.ExtraBold, modifier = Modifier @@ -113,7 +116,7 @@ class AddFriendDialog( value = searchKeyword.value, onValueChange = { searchKeyword.value = it }, label = { - Text(text = "Search") + Text(text = translation["search_hint"]) }, modifier = Modifier .weight(1f) @@ -184,7 +187,7 @@ class AddFriendDialog( ) { if (hasFetchError) { Text( - text = "Failed to fetch data", + text = translation["fetch_error"], fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) @@ -222,7 +225,7 @@ class AddFriendDialog( ) { item { if (filteredGroups.isEmpty()) return@item - Text(text = "Groups", + Text(text = translation["category_groups"], fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) @@ -248,7 +251,7 @@ class AddFriendDialog( item { if (filteredFriends.isEmpty()) return@item - Text(text = "Friends", + Text(text = translation["category_friends"], fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 10.dp, top = 10.dp) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt index 6ca75dd03..8010e58aa 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -40,6 +40,8 @@ class ScopeContent( val scope: SocialScope, private val id: String ) { + private val translation by lazy { context.translation.getCategory("manager.sections.social") } + fun deleteScope(coroutineScope: CoroutineScope) { when (scope) { SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) @@ -68,7 +70,7 @@ class ScopeContent( val rules = context.modDatabase.getRules(id) - SectionTitle("Rules") + SectionTitle(translation["rules_title"]) ContentCard { //manager anti features etc @@ -163,7 +165,7 @@ class ScopeContent( private fun Friend() { //fetch the friend from the database val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { - Text(text = "Friend not found") + Text(text = translation["not_found"]) return } @@ -197,9 +199,6 @@ class ScopeContent( fontSize = 12.sp, fontWeight = FontWeight.Light ) - // Spacer(modifier = Modifier.height(16.dp)) - - //DeleteScopeEntityButton() } Spacer(modifier = Modifier.height(16.dp)) @@ -207,7 +206,7 @@ class ScopeContent( //streaks streaks?.let { var shouldNotify by remember { mutableStateOf(it.notify) } - SectionTitle("Streaks") + SectionTitle(translation["streaks_title"]) ContentCard { Row( verticalAlignment = Alignment.CenterVertically @@ -215,13 +214,13 @@ class ScopeContent( Column( modifier = Modifier.weight(1f), ) { - Text(text = "Length: ${streaks.length}", maxLines = 1) - Text(text = "Expires in: ${computeStreakETA(streaks.expirationTimestamp)}", maxLines = 1) + Text(text = translation.format("streaks_length_text", "count" to streaks.length.toString()), maxLines = 1) + Text(text = translation.format("streaks_expiration_text", "eta" to computeStreakETA(streaks.expirationTimestamp)), maxLines = 1) } Row( verticalAlignment = Alignment.CenterVertically ) { - Text(text = "Reminder", maxLines = 1, modifier = Modifier.padding(end = 10.dp)) + Text(text = translation["reminder_button"], maxLines = 1, modifier = Modifier.padding(end = 10.dp)) Switch(checked = shouldNotify, onCheckedChange = { context.modDatabase.setFriendStreaksNotify(id, it) shouldNotify = it @@ -237,7 +236,7 @@ class ScopeContent( private fun Group() { //fetch the group from the database val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { - Text(text = "Group not found") + Text(text = translation["not_found"]) return } @@ -256,7 +255,7 @@ class ScopeContent( ) Spacer(modifier = Modifier.height(5.dp)) Text( - text = "Participants: ${group.participantsCount}", + text = translation.format("participants_text", "count" to group.participantsCount.toString()), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index 3a734f1ba..b8f87559f 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -219,7 +219,7 @@ class SocialSection : Section() { MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary ) - Text(text = "${streaks.hoursLeft()}h", maxLines = 1, fontWeight = FontWeight.Bold) + Text(text = context.translation.format("context.sections.social.streaks_expiration_short", "hours" to streaks.length.toString()), maxLines = 1, fontWeight = FontWeight.Bold) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt index 7fd872fb4..e855026c3 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/PermissionsScreen.kt @@ -15,7 +15,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import me.rhunk.snapenhance.ui.setup.screens.SetupScreen @@ -38,6 +43,24 @@ class PermissionsScreen : SetupScreen() { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } + @Composable + private fun RequestButton(onClick: () -> Unit) { + Button(onClick = onClick) { + Text(text = context.translation["setup.permissions.request_button"]) + } + } + + @Composable + private fun GrantedIcon() { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(5.dp) + ) + } + @SuppressLint("BatteryLife") @Composable override fun Content() { @@ -59,7 +82,7 @@ class PermissionsScreen : SetupScreen() { allowNext(false) } - DialogText(text = "To continue you need to fit the following requirements:") + DialogText(text = context.translation["setup.permissions.dialog"]) OutlinedCard( modifier = Modifier @@ -73,39 +96,36 @@ class PermissionsScreen : SetupScreen() { Row( horizontalArrangement = Arrangement.Absolute.SpaceAround ) { - DialogText(text = "Notification access", modifier = Modifier.weight(1f)) + DialogText(text = context.translation["setup.permissions.dialog"], modifier = Modifier.weight(1f)) if (notificationPermissionGranted) { - DialogText(text = "Granted") - } else { - Button(onClick = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activityLauncherHelper.requestPermission(Manifest.permission.POST_NOTIFICATIONS) { resultCode, _ -> - coroutineScope.launch { - notificationPermissionGranted = resultCode == ComponentActivity.RESULT_OK - } + GrantedIcon() + return@Row + } + + RequestButton { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activityLauncherHelper.requestPermission(Manifest.permission.POST_NOTIFICATIONS) { resultCode, _ -> + coroutineScope.launch { + notificationPermissionGranted = resultCode == ComponentActivity.RESULT_OK } } - }) { - Text(text = "Request") } } } Row { - DialogText(text = "Battery optimisation", modifier = Modifier.weight(1f)) + DialogText(text = context.translation["setup.permissions.battery_optimization"], modifier = Modifier.weight(1f)) if (isBatteryOptimisationIgnored) { - DialogText(text = "Ignored") - } else { - Button(onClick = { - activityLauncherHelper.launch(Intent().apply { - action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = Uri.parse("package:${context.androidContext.packageName}") - }) { resultCode, _ -> - coroutineScope.launch { - isBatteryOptimisationIgnored = resultCode == 0 - } + GrantedIcon() + return@Row + } + RequestButton { + activityLauncherHelper.launch(Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:${context.androidContext.packageName}") + }) { resultCode, _ -> + coroutineScope.launch { + isBatteryOptimisationIgnored = resultCode == 0 } - }) { - Text(text = "Request") } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt index 29d24de04..88d7fa6a0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -210,7 +210,7 @@ class AlertDialogs( horizontalArrangement = Arrangement.SpaceEvenly, ) { Button(onClick = { dismiss() }) { - Text(text = "Cancel") + Text(text = translation["button.cancel"]) } Button(onClick = { when (property.key.dataType.type) { @@ -232,7 +232,7 @@ class AlertDialogs( } dismiss() }) { - Text(text = "Ok") + Text(text = translation["button.ok"]) } } } diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 89acfb7a3..a1fe165bf 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -12,6 +12,12 @@ "generate_failure_no_snapchat": "SnapEnhance was unable to detect Snapchat, please try reinstalling Snapchat.", "generate_failure": "An error occurred while trying to generate mappings, please try again.", "generate_success": "Mappings generated successfully." + }, + "permissions": { + "dialog": "To continue you need to fit the following requirements:", + "notification_access": "Notification Access", + "battery_optimization": "Battery Optimization", + "request_button": "Request" } }, @@ -25,8 +31,38 @@ "social": "Social", "plugins": "Plugins" }, - "features": { - "disabled": "Disabled" + "sections": { + "home": { + "logs": { + "clear_logs_button": "Clear Logs", + "export_logs_button": "Export Logs" + } + }, + "downloads": { + "empty_download_list": "(empty)" + }, + "features": { + "disabled": "Disabled" + }, + "social": { + "rules_title": "Rules", + "participants_text": "{count} participants", + "not_found": "Not found", + "streaks_title": "Streaks", + "streaks_length_text": "Length: {length}", + "streaks_expiration_short": "{hours}h", + "streaks_expiration_text": "Expires in {eta}", + "reminder_button": "Set Reminder" + } + }, + "dialogs": { + "add_friend": { + "title": "Add Friend or Group", + "search_hint": "Search", + "fetch_error": "Failed to fetch data", + "category_groups": "Groups", + "category_friends": "Friends" + } } }, @@ -118,6 +154,10 @@ "name": "Force Voice Note Format", "description": "Forces Voice Notes to be saved in a specified Format" }, + "download_profile_pictures": { + "name": "Download Profile Pictures", + "description": "Allows you to download Profile Pictures from the profile page" + }, "chat_download_context_menu": { "name": "Chat Download Context Menu", "description": "Allows you to download media from a conversation by long-pressing them" @@ -577,16 +617,6 @@ "birthday": "Birthday : {month} {day}" }, - "auto_updater": { - "no_update_available": "No Update available!", - "dialog_title": "New Update available!", - "dialog_message": "There is a new Update for SnapEnhance available! ({version})\n\n{body}", - "dialog_positive_button": "Download and Install", - "dialog_negative_button": "Cancel", - "downloading_toast": "Downloading Update...", - "download_manager_notification_title": "Downloading SnapEnhance APK..." - }, - "chat_export": { "select_export_format": "Select the Export Format", "select_media_type": "Select Media Types to export", @@ -610,7 +640,15 @@ "positive": "Yes", "negative": "No", "cancel": "Cancel", - "open": "Open" + "open": "Open", + "download": "Download" + }, + + "profile_picture_downloader": { + "button": "Download Profile Picture", + "title": "Profile Picture Downloader", + "avatar_option": "Avatar", + "background_option": "Background" }, "download_manager_activity": { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt index 47202fd1e..cb377cccc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/DownloaderConfig.kt @@ -41,6 +41,7 @@ class DownloaderConfig : ConfigContainer() { val forceVoiceNoteFormat = unique("force_voice_note_format", "aac", "mp3", "opus") { addFlags(ConfigFlag.NO_TRANSLATE) } + val downloadProfilePictures = boolean("download_profile_pictures") val chatDownloadContextMenu = boolean("chat_download_context_menu") val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt index 056dc3578..3d846fc88 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/RootConfig.kt @@ -11,6 +11,7 @@ class RootConfig : ConfigContainer() { val rules = container("rules", Rules()) { icon = "Rule" } val camera = container("camera", Camera()) { icon = "Camera"} val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } - val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices(FeatureNotice.UNSTABLE) + val experimental = container("experimental", Experimental()) { + icon = "Science"; addNotices(FeatureNotice.UNSTABLE) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt index 555f45b73..5cd16311a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt @@ -16,6 +16,8 @@ import java.nio.ByteBuffer class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { @SuppressLint("SetTextI18n") override fun asyncOnActivityCreate() { + if (!context.config.downloader.downloadProfilePictures.get()) return + var friendUsername: String? = null var backgroundUrl: String? = null var avatarUrl: String? = null @@ -24,7 +26,7 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe event.parent.addView(Button(event.parent.context).apply { - text = "Download" + text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"] layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply { setMargins(0, 200, 0, 0) } @@ -32,12 +34,14 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams ViewAppearanceHelper.newAlertDialogBuilder( this@ProfilePictureDownloader.context.mainActivity!! ).apply { - setTitle("Download profile picture") + setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"]) val choices = mutableMapOf() - backgroundUrl?.let { choices["Background"] = it } - avatarUrl?.let { choices["Avatar"] = it } + backgroundUrl?.let { choices["avatar_option"] = it } + avatarUrl?.let { choices["background_option"] = it } - setItems(choices.keys.toTypedArray()) { _, which -> + setItems(choices.keys.map { + this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"] + }.toTypedArray()) { _, which -> runCatching { this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture( choices.values.elementAt(which), From 6a32c69404bc6ac92748c617293b58d0db268f98 Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sun, 3 Sep 2023 12:07:07 +0200 Subject: [PATCH 029/274] feat(spoofer): more spoof options * installer package name * debug flag * mock location * split classloader --- core/src/main/assets/lang/en_US.json | 40 ++++++++++++++++++- .../snapenhance/core/config/impl/Spoof.kt | 11 +++-- .../rhunk/snapenhance/data/SnapClassCache.kt | 1 + .../impl/experiments/DeviceSpooferHook.kt | 39 +++++++++++++++++- 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index a1fe165bf..7e3870feb 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -428,11 +428,47 @@ "properties": { "location": { "name": "Location", - "description": "Spoof your location" + "description": "Spoof your location", + "properties": { + "location_latitude": { + "name": "Latitude", + "description": "The latitude of the location" + }, + "location_longitude": { + "name": "Longitude", + "description": "The longitude of the location" + } + } }, "device": { "name": "Device", - "description": "Spoof your device information" + "description": "Spoof your device information", + "properties": { + "fingerprint": { + "name": "Device Fingerprint", + "description": "Spoofs your device Fingerprint" + }, + "android_id": { + "name": "Android ID", + "description": "SpoofS your Android ID to the specified value" + }, + "installer_package_name": { + "name": "Installer Package name", + "description": "Spoofs the installers Package name" + }, + "debug_flag": { + "name": "Debug Flag", + "description": "Makes Snapchat debuggable" + }, + "mock_location": { + "name": "Mock location", + "description": "Spoofs the Mock Location device state" + }, + "split_classloader": { + "name": "Split Classloader", + "description": "Spoofs splitClassloader\nRequested by org.chromium.base.JNIUtils" + } + } } } }, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt index 5e53ecef3..8139a937d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.core.config.impl import me.rhunk.snapenhance.core.config.ConfigContainer +import me.rhunk.snapenhance.core.config.FeatureNotice class Spoof : ConfigContainer() { inner class Location : ConfigContainer(hasGlobalState = true) { @@ -10,8 +11,12 @@ class Spoof : ConfigContainer() { val location = container("location", Location()) inner class Device : ConfigContainer(hasGlobalState = true) { - val fingerprint = string("device_fingerprint") - val androidId = string("device_android_id") + val fingerprint = string("fingerprint") + val androidId = string("android_id") + val getInstallerPackageName = string("installer_package_name") + val debugFlag = boolean("debug_flag") + val mockLocationState = boolean("mock_location") + val splitClassLoader = string("split_classloader") } - val device = container("device", Device()) + val device = container("device", Device()) { addNotices(FeatureNotice.BAN_RISK) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt index 5af39326f..b0bd6dbc8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt @@ -16,6 +16,7 @@ class SnapClassCache ( val feedEntry by lazy { findClass("com.snapchat.client.messaging.FeedEntry") } val conversation by lazy { findClass("com.snapchat.client.messaging.Conversation") } val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") } + val chromiumJNIUtils by lazy { findClass("org.chromium.base.JNIUtils")} private fun findClass(className: String): Class<*> { return try { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt index a9f26e728..28ab73362 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt @@ -11,9 +11,17 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam val fingerprint by context.config.experimental.spoof.device.fingerprint val androidId by context.config.experimental.spoof.device.androidId + val getInstallerPackageName by context.config.experimental.spoof.device.getInstallerPackageName + val debugFlag by context.config.experimental.spoof.device.debugFlag + val mockLocationState by context.config.experimental.spoof.device.mockLocationState + val splitClassLoader by context.config.experimental.spoof.device.splitClassLoader + + val settingsSecureClass = android.provider.Settings.Secure::class.java + val fingerprintClass = android.os.Build::class.java + val packageManagerClass = android.content.pm.PackageManager::class.java + val applicationInfoClass = android.content.pm.ApplicationInfo::class.java if (fingerprint.isNotEmpty()) { - val fingerprintClass = android.os.Build::class.java Hooker.hook(fingerprintClass, "FINGERPRINT", HookStage.BEFORE) { hookAdapter -> hookAdapter.setResult(fingerprint) context.log.verbose("Fingerprint spoofed to $fingerprint") @@ -25,7 +33,6 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam } if (androidId.isNotEmpty()) { - val settingsSecureClass = android.provider.Settings.Secure::class.java Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> if(hookAdapter.args()[1] == "android_id") { hookAdapter.setResult(androidId) @@ -33,5 +40,33 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam } } } + + //TODO: org.chromium.base.BuildInfo, org.chromium.base.PathUtils getDataDirectory, MushroomDeviceTokenManager(?), TRANSPORT_VPN FLAG, isFromMockProvider, nativeLibraryDir, sourceDir, network capabilities, query all jvm properties + + //INSTALLER PACKAGE NAME + if(getInstallerPackageName.isNotEmpty()) { + Hooker.hook(packageManagerClass, "getInstallerPackageName", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(getInstallerPackageName) + } + } + + //DEBUG FLAG + Hooker.hook(applicationInfoClass, "FLAG_DEBUGGABLE", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(debugFlag) + } + + //MOCK LOCATION + Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> + if(hookAdapter.args()[1] == "ALLOW_MOCK_LOCATION") { + hookAdapter.setResult(mockLocationState) + } + } + + //GET SPLIT CLASSLOADER + if(splitClassLoader.isNotEmpty()) { + Hooker.hook(context.classCache.chromiumJNIUtils, "getSplitClassLoader", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(splitClassLoader) + } + } } } \ No newline at end of file From a61985e24747b70bf51e1ed02b6c446b9c3474c0 Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sun, 3 Sep 2023 12:10:20 +0200 Subject: [PATCH 030/274] fix(lang): typo --- core/src/main/assets/lang/en_US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 7e3870feb..06e2ec591 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -450,7 +450,7 @@ }, "android_id": { "name": "Android ID", - "description": "SpoofS your Android ID to the specified value" + "description": "Spoofs your Android ID to the specified value" }, "installer_package_name": { "name": "Installer Package name", From 721ff6927e66198b4cb4542288465f5a20616e43 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Sep 2023 12:59:28 +0200 Subject: [PATCH 031/274] chore: credits --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 5082f60fe..634eb6c81 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,14 @@ When redistributing the software, it must remain under the same [GPLv3](https:// - BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92 - ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92 +## Credits +- [LSPosed](https://github.com/LSPosed/LSPosed) +- [dexlib2](https://android.googlesource.com/platform/external/smali/+/refs/heads/main/dexlib2/) +- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) +- [osmdroid](https://github.com/osmdroid/osmdroid) +- [coil](https://github.com/coil-kt/coil) +- [Dobby](https://github.com/jmpews/Dobby) + ## Contributors - [rathmerdominik](https://github.com/rathmerdominik) - [Flole998](https://github.com/Flole998) From 2b682d18f8f42924f151d6379a19439758d745a0 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Sep 2023 12:59:53 +0200 Subject: [PATCH 032/274] fix: ui translation keys --- .../manager/sections/social/ScopeContent.kt | 48 +++++++++++-------- .../manager/sections/social/SocialSection.kt | 6 ++- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt index 8010e58aa..f07a4701e 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -58,8 +58,7 @@ class ScopeContent( @Composable fun Content() { Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) + modifier = Modifier.verticalScroll(rememberScrollState()) ) { when (scope) { SocialScope.FRIEND -> Friend() @@ -92,10 +91,12 @@ class ScopeContent( maxLines = 1, modifier = Modifier.weight(1f) ) - Switch(checked = ruleEnabled, enabled = if (ruleType.listMode) ruleState != null else true, onCheckedChange = { - context.modDatabase.setRule(id, ruleType.key, it) - ruleEnabled = it - }) + Switch(checked = ruleEnabled, + enabled = if (ruleType.listMode) ruleState != null else true, + onCheckedChange = { + context.modDatabase.setRule(id, ruleType.key, it) + ruleEnabled = it + }) } } } @@ -180,9 +181,7 @@ class ScopeContent( horizontalAlignment = Alignment.CenterHorizontally ) { val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie( - friend.selfieId, - friend.bitmojiId, - BitmojiSelfie.BitmojiSelfieType.THREE_D + friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D ) BitmojiImage(context = context, url = bitmojiUrl, size = 100) Spacer(modifier = Modifier.height(16.dp)) @@ -214,13 +213,26 @@ class ScopeContent( Column( modifier = Modifier.weight(1f), ) { - Text(text = translation.format("streaks_length_text", "count" to streaks.length.toString()), maxLines = 1) - Text(text = translation.format("streaks_expiration_text", "eta" to computeStreakETA(streaks.expirationTimestamp)), maxLines = 1) + Text( + text = translation.format( + "streaks_length_text", "length" to streaks.length.toString() + ), maxLines = 1 + ) + Text( + text = translation.format( + "streaks_expiration_text", + "eta" to computeStreakETA(streaks.expirationTimestamp) + ), maxLines = 1 + ) } Row( verticalAlignment = Alignment.CenterVertically ) { - Text(text = translation["reminder_button"], maxLines = 1, modifier = Modifier.padding(end = 10.dp)) + Text( + text = translation["reminder_button"], + maxLines = 1, + modifier = Modifier.padding(end = 10.dp) + ) Switch(checked = shouldNotify, onCheckedChange = { context.modDatabase.setFriendStreaksNotify(id, it) shouldNotify = it @@ -248,17 +260,13 @@ class ScopeContent( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = group.name, - maxLines = 1, - fontSize = 20.sp, - fontWeight = FontWeight.Bold + text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(5.dp)) Text( - text = translation.format("participants_text", "count" to group.participantsCount.toString()), - maxLines = 1, - fontSize = 12.sp, - fontWeight = FontWeight.Light + text = translation.format( + "participants_text", "count" to group.participantsCount.toString() + ), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light ) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index b8f87559f..8f5558a8d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -219,7 +219,11 @@ class SocialSection : Section() { MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary ) - Text(text = context.translation.format("context.sections.social.streaks_expiration_short", "hours" to streaks.length.toString()), maxLines = 1, fontWeight = FontWeight.Bold) + Text( + text = context.translation.format("manager.sections.social.streaks_expiration_short", "hours" to ((streaks.expirationTimestamp - System.currentTimeMillis()) / 3600000).toInt().toString()), + maxLines = 1, + fontWeight = FontWeight.Bold + ) } } } From 70784da38edde672a4dc5901d0b7ce0d6165e949 Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sun, 3 Sep 2023 15:53:23 +0200 Subject: [PATCH 033/274] chore(readme): privacy section --- README.md | 66 +++++++++++++++++++++----------- app/src/main/AndroidManifest.xml | 2 - 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 634eb6c81..ecf951403 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,6 @@ SnapEnhance is an Xposed mod that enhances your Snapchat experience.

Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues, we encourage you to report them. To do this simply visit our [issues](https://github.com/rhunk/SnapEnhance/issues) page and create an issue, make sure to follow the guidelines. -## Download -To Download the latest stable release, please visit the [Releases](https://github.com/rhunk/SnapEnhance/releases) page.
-You can also download the latest debug build from the [Actions](https://github.com/rhunk/SnapEnhance/actions) section.
-We no longer offer official LSPatch binaries for obvious reasons. However, you're welcome to patch them yourself, as they should theoretically work without any issues. - ## Quick Start Requirements: - Rooted using Magisk or KernelSU @@ -25,6 +20,11 @@ Although using this in an unrooted enviroment using something like LSPatch shoul 3. Force Stop Snapchat 4. Open the menu by clicking the [Settings Gear Icon](https://i.imgur.com/2grm8li.png) +## Download +To Download the latest stable release, please visit the [Releases](https://github.com/rhunk/SnapEnhance/releases) page.
+You can also download the latest debug build from the [Actions](https://github.com/rhunk/SnapEnhance/actions) section.
+We no longer offer official LSPatch binaries for obvious reasons. However, you're welcome to patch them yourself, as they should theoretically work without any issues. + ## Features
Spying & Privacy @@ -82,24 +82,26 @@ Although using this in an unrooted enviroment using something like LSPatch shoul - Chat Export (HTML, JSON and TXT)
-## License -The [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license is a free, open-source software license that grants users the right to modify, share, and redistribute the software.
-By using this software, you agree to make the source code freely available, along with any modifications, additions, or derivatives.
-When redistributing the software, it must remain under the same [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license, and any modifications should be clearly indicated as such.
+## Privacy -## Donate -- LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE -- BCH: qpu57a05kqljjadvpgjc6t894apprvth9slvlj4vpj -- BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92 -- ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92 +We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies. +
+ Permissions + + - [android.permission.INTERNET](https://developer.android.com/reference/android/Manifest.permission#INTERNET) + - [android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS](https://developer.android.com/reference/android/Manifest.permission.html#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + - [android.permission.POST_NOTIFICATIONS](https://developer.android.com/reference/android/Manifest.permission.html#POST_NOTIFICATIONS) +
-## Credits -- [LSPosed](https://github.com/LSPosed/LSPosed) -- [dexlib2](https://android.googlesource.com/platform/external/smali/+/refs/heads/main/dexlib2/) -- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) -- [osmdroid](https://github.com/osmdroid/osmdroid) -- [coil](https://github.com/coil-kt/coil) -- [Dobby](https://github.com/jmpews/Dobby) +
+ Third-party libraries used + + - [libxposed](https://github.com/libxposed/api) + - [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) + - [osmdroid](https://github.com/osmdroid/osmdroid) + - [coil](https://github.com/coil-kt/coil) + - [Dobby](https://github.com/jmpews/Dobby) +
## Contributors - [rathmerdominik](https://github.com/rathmerdominik) @@ -108,4 +110,24 @@ When redistributing the software, it must remain under the same [GPLv3](https:// - [RevealedSoulEven](https://github.com/revealedsouleven) - [iBasim](https://github.com/ibasim) - [xerta555](https://github.com/xerta555) -- [TheVisual](https://github.com/TheVisual) +- [TheVisual](https://github.com/TheVisual) + +## Credits +- [LSPosed](https://github.com/LSPosed/LSPosed) +- [dexlib2](https://android.googlesource.com/platform/external/smali/+/refs/heads/main/dexlib2/) +- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) +- [osmdroid](https://github.com/osmdroid/osmdroid) +- [coil](https://github.com/coil-kt/coil) +- [Dobby](https://github.com/jmpews/Dobby) + + +## Donate +- LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE +- BCH: qpu57a05kqljjadvpgjc6t894apprvth9slvlj4vpj +- BTC: bc1qaqnfn6mauzhmx0e6kkenh2wh4r6js0vh5vel92 +- ETH: 0x0760987491e9de53A73fd87F092Bd432a227Ee92 + +## License +The [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license is a free, open-source software license that grants users the right to modify, share, and redistribute the software. +By using this software, you agree to make the source code freely available, along with any modifications, additions, or derivatives. +When redistributing the software, it must remain under the same [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html#license-text) license, and any modifications should be clearly indicated as such. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c4cb17857..dee4fe07b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,8 +4,6 @@ - - From 080aaf93384c50e10cfdd7d1bba80641f534c5fb Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sun, 3 Sep 2023 16:10:06 +0200 Subject: [PATCH 034/274] fix: null handling --- app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 8209044e5..71e275fa8 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -101,7 +101,7 @@ class RemoteSideContext( version = it.versionName, versionCode = it.longVersionCode, isLSPatched = it.applicationInfo.appComponentFactory != CoreComponentFactory::class.java.name, - isSplitApk = it.splitNames.isNotEmpty() + isSplitApk = it.splitNames?.isNotEmpty() ?: false ) }, modInfo = ModInfo( From 0fed75b9fff09085f52ec003c8d3f3203b6cfd5d Mon Sep 17 00:00:00 2001 From: authorisation <64337177+authorisation@users.noreply.github.com> Date: Sun, 3 Sep 2023 18:40:52 +0200 Subject: [PATCH 035/274] feat(spoofer): even more spoof options didn't do the translations cuz lazy i'll do it later I'll add even more options later aswell --- .../snapenhance/core/config/impl/Spoof.kt | 2 ++ .../rhunk/snapenhance/data/SnapClassCache.kt | 2 ++ .../impl/experiments/DeviceSpooferHook.kt | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt index 8139a937d..d8c2acd52 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Spoof.kt @@ -17,6 +17,8 @@ class Spoof : ConfigContainer() { val debugFlag = boolean("debug_flag") val mockLocationState = boolean("mock_location") val splitClassLoader = string("split_classloader") + val isLowEndDevice = string("low_end_device") + val getDataDirectory = string("get_data_directory") } val device = container("device", Device()) { addNotices(FeatureNotice.BAN_RISK) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt index b0bd6dbc8..64eb53784 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt @@ -17,6 +17,8 @@ class SnapClassCache ( val conversation by lazy { findClass("com.snapchat.client.messaging.Conversation") } val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") } val chromiumJNIUtils by lazy { findClass("org.chromium.base.JNIUtils")} + val chromiumBuildInfo by lazy { findClass("org.chromium.base.BuildInfo")} + val chromiumPathUtils by lazy { findClass("org.chromium.base.PathUtils")} private fun findClass(className: String): Class<*> { return try { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt index 28ab73362..68d4a7f85 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/DeviceSpooferHook.kt @@ -15,12 +15,15 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam val debugFlag by context.config.experimental.spoof.device.debugFlag val mockLocationState by context.config.experimental.spoof.device.mockLocationState val splitClassLoader by context.config.experimental.spoof.device.splitClassLoader + val isLowEndDevice by context.config.experimental.spoof.device.isLowEndDevice + val getDataDirectory by context.config.experimental.spoof.device.getDataDirectory val settingsSecureClass = android.provider.Settings.Secure::class.java val fingerprintClass = android.os.Build::class.java val packageManagerClass = android.content.pm.PackageManager::class.java val applicationInfoClass = android.content.pm.ApplicationInfo::class.java + //FINGERPRINT if (fingerprint.isNotEmpty()) { Hooker.hook(fingerprintClass, "FINGERPRINT", HookStage.BEFORE) { hookAdapter -> hookAdapter.setResult(fingerprint) @@ -32,6 +35,7 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam } } + //ANDROID ID if (androidId.isNotEmpty()) { Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> if(hookAdapter.args()[1] == "android_id") { @@ -68,5 +72,22 @@ class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParam hookAdapter.setResult(splitClassLoader) } } + + //ISLOWENDDEVICE + if(isLowEndDevice.isNotEmpty()) { + Hooker.hook(context.classCache.chromiumBuildInfo, "getAll", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(isLowEndDevice) + } + } + + //GETDATADIRECTORY + if(getDataDirectory.isNotEmpty()) { + Hooker.hook(context.classCache.chromiumPathUtils, "getDataDirectory", HookStage.BEFORE) {hookAdapter -> + hookAdapter.setResult(getDataDirectory) + } + } + + //accessibility_enabled + } } \ No newline at end of file From 7c2f23f084cb94921cf4a489cc057feb947e8032 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 4 Sep 2023 00:23:19 +0200 Subject: [PATCH 036/274] chore(lang): missing keys - add french --- core/src/main/assets/lang/en_US.json | 50 +- core/src/main/assets/lang/fr_FR.json | 936 +++++++++++------- .../snapenhance/core/config/ConfigObjects.kt | 2 +- .../features/impl/tweaks/SendOverride.kt | 4 +- 4 files changed, 583 insertions(+), 409 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 06e2ec591..0aa7fc266 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -3,8 +3,7 @@ "dialogs": { "select_language": "Select Language", "save_folder": "SnapEnhance requires Storage permissions to download and Save Media from Snapchat.\nPlease choose the location where media should be downloaded to.", - "select_save_folder_button": "Select Folder", - "mappings": "To dynamically support a wide range of Snapchat Versions, mappings are necessary for SnapEnhance to function properly, this should not take more than 5 seconds." + "select_save_folder_button": "Select Folder" }, "mappings": { "dialog": "To dynamically support a wide range of Snapchat Versions, mappings are necessary for SnapEnhance to function properly, this should not take more than 5 seconds.", @@ -562,7 +561,7 @@ "ORIGINAL": "Original", "NOTE": "Audio Note", "SNAP": "Snap", - "LIVE_SNAP": "Snap with audio" + "SAVABLE_SNAP": "Savable Snap" }, "hide_ui_components": { "hide_call_buttons": "Remove Call Buttons", @@ -571,12 +570,6 @@ "hide_stickers_button": "Remove Stickers Button", "hide_voice_record_button": "Remove Voice Record Button" }, - "auto_updater": { - "DISABLED": "Disabled", - "EVERY_LAUNCH": "On Every Launch", - "DAILY": "Daily", - "WEEKLY": "Weekly" - }, "story_viewer_override": { "OFF": "Off", "DISCOVER_PLAYBACK_SEEKBAR": "Enable Discover Playback Seekbar", @@ -614,11 +607,6 @@ "anti_auto_save": "Anti Auto Save" }, - "message_context_menu_option": { - "download": "Download", - "preview": "Preview" - }, - "chat_action_menu": { "preview_button": "Preview", "download_button": "Download", @@ -687,31 +675,6 @@ "background_option": "Background" }, - "download_manager_activity": { - "remove_all_title": "Remove all Downloads", - "remove_all_text": "Are you sure you want to do this?", - "remove_all": "Remove All", - "no_downloads": "No downloads", - "cancel": "Cancel", - "file_not_found_toast": "File does not exist!", - "category": { - "all_category": "All", - "pending_category": "Pending", - "snap_category": "Snaps", - "story_category": "Stories", - "spotlight_category": "Spotlight" - }, - "debug_settings": "Debug Settings", - "debug_settings_page": { - "clear_file_title": "Clear {file_name} file", - "clear_file_confirmation": "Are you sure you want to clear the {file_name} file?", - "clear_cache_title": "Clear Cache", - "reset_all_title": "Reset all settings", - "reset_all_confirmation": "Are you sure you want to reset all settings?", - "success_toast": "Success!", - "device_spoofer": "Device Spoofer" - } - }, "download_processor": { "download_started_toast": "Download started", "unsupported_content_type_toast": "Unsupported content type!", @@ -726,14 +689,7 @@ "failed_processing_toast": "Failed processing {error}", "failed_gallery_toast": "Failed saving to gallery {error}" }, - "config_activity": { - "title": "SnapEnhance Settings", - "selected_text": "{count} selected", - "invalid_number_toast": "Invalid number!" - }, - "spoof_activity": { - "title": "Spoof Settings" - }, + "streaks_reminder": { "notification_title": "Streaks", "notification_text": "You will lose your Streak with {friend} in {hoursLeft} hours" diff --git a/core/src/main/assets/lang/fr_FR.json b/core/src/main/assets/lang/fr_FR.json index 04f6937fb..11b0a5cd6 100644 --- a/core/src/main/assets/lang/fr_FR.json +++ b/core/src/main/assets/lang/fr_FR.json @@ -1,9 +1,22 @@ { "setup": { "dialogs": { - "select_language": "Selectionner une langue", - "save_folder": "Pour télécharger les médias Snapchat, vous devez choisir un emplacement de sauvegarde. Cela peut être modifié plus tard dans les paramètres de l'application.", - "select_save_folder_button": "Choisir un emplacement de sauvegarde" + "select_language": "Choisissez votre langue", + "save_folder": "SnapEnhance requiert des permissions de stockage pour télécharger et sauvegarder les médias de Snapchat\nChoisissez un emplacement de sauvegarde pour les médias téléchargés", + "select_save_folder_button": "Choisir le dossier" + }, + "mappings": { + "dialog": "Pour prendre en charge de nombreuses versions de Snapchat de manière dynamique, des mappages sont nécessaires pour que SnapEnhance fonctionne correctement, ce qui ne devrait pas prendre plus de 5 secondes.", + "generate_button": "Générer", + "generate_failure_no_snapchat": "SnapEnhance n'a pas pu détecter Snapchat, veuillez essayer de réinstaller Snapchat.", + "generate_failure": "Une erreur s'est produite lors de la génération des mappings, veuillez réessayer.", + "generate_success": "Les mappages ont été générés avec succès." + }, + "permissions": { + "dialog": "Pour continuer, vous devez remplir les conditions suivantes :", + "notification_access": "Accès aux notifications", + "battery_optimization": "Désactiver l'optimisation de la batterie", + "request_button": "Autoriser" } }, @@ -12,294 +25,523 @@ "downloads": "Téléchargements", "features": "Fonctionnalités", "home": "Accueil", - "friends": "Amis", - "debug": "Débogage" + "home_debug": "Débug", + "home_logs": "Logs", + "social": "Social", + "plugins": "Plugins" + }, + "sections": { + "home": { + "logs": { + "clear_logs_button": "Effacer les logs", + "export_logs_button": "Exporter les logs" + } + }, + "downloads": { + "empty_download_list": "(vide)" + }, + "features": { + "disabled": "Désactivé" + }, + "social": { + "rules_title": "Règles", + "participants_text": "{count} participants", + "not_found": "Introuvable", + "streaks_title": "Flammes", + "streaks_length_text": "Durée {length}", + "streaks_expiration_short": "{hours}h", + "streaks_expiration_text": "Expire dans {eta}", + "reminder_button": "Activer les rappels" + } }, - "features": { - "disabled": "Désactivé" + "dialogs": { + "add_friend": { + "title": "Ajouter un ami ou un groupe", + "search_hint": "Rechercher", + "fetch_error": "Impossible de récupérer la liste de données", + "category_groups": "Groupes", + "category_friends": "Amis" + } } }, - "category": { - "spying_privacy": "Espionnage et vie privée", - "media_manager": "Gestionnaire de média", - "ui_tweaks": "Interface & Ajustements", - "camera": "Caméra", - "updates": "Mises à jour", - "experimental_debugging": "Expérimental" + "rules": { + "modes": { + "blacklist": "Mode liste noire", + "whitelist": "Mode liste blanche" + }, + "properties": { + "auto_download": { + "name": "Téléchargement automatique", + "description": "Télécharge automatiquement les médias de Snapchat lorsqu'ils sont vus", + "options": { + "blacklist": "Exclure du téléchargement automatique", + "whitelist": "Téléchargement automatique" + } + }, + "stealth": { + "name": "Mode furtif", + "description": "Empêche quiconque de savoir que vous avez ouvert leurs Snaps/Chats et conversations", + "options": { + "blacklist": "Exclure du mode furtif", + "whitelist": "Mode furtif" + } + }, + "auto_save": { + "name": "Sauvegarde automatique", + "description": "Sauvegarde automatiquement les messages de chat lorsqu'ils sont vus", + "options": { + "blacklist": "Exclure de la sauvegarde automatique", + "whitelist": "Sauvegarde automatique" + } + }, + "hide_chat_feed": { + "name": "Masquer du flux de chat" + } + } }, - "action": { - "clean_cache": "Vider le cache", - "clear_message_logger": "Effacer les logs des messages", - "refresh_mappings": "Actualiser les mappages", - "open_map": "Choisir un emplacement sur la carte", - "check_for_updates": "Vérifier les mises à jour", - "export_chat_messages": "Exporter les messages du chat" + + "actions": { + "clean_snapchat_cache": "Nettoyer le cache de Snapchat", + "clear_message_logger": "Effacer le journal des messages", + "refresh_mappings": "Rafraîchir les mappages", + "open_map": "Ouvrir la carte", + "export_chat_messages": "Exporter les messages de chat" }, - "property": { - "message_logger": { - "name": "Message Logger", - "description": "Empêche l'effacement des messages" - }, - "prevent_read_receipts": { - "name": "Empêcher la vu des messages", - "description": "Empêcher n'importe qui de savoir que vous avez ouvert leurs snaps/chats" - }, - "hide_bitmoji_presence": { - "name": "Cacher la présence du Bitmoji", - "description": "Masque la présence de votre Bitmoji dans le chat" - }, - "better_notifications": { - "name": "Notifications améliorées", - "description": "Afficher plus d'information dans les notifications" - }, - "notification_blacklist": { - "name": "Liste noire des notifications", - "description": "Masque le type de notification sélectionné" - }, - "disable_metrics": { - "name": "Désactiver les Metrics", - "description": "Désactive les Metrics envoyées à Snapchat" - }, - "block_ads": { - "name": "Bloquer les publicités", - "description": "Empêcher les publicités de s'afficher" - }, - "unlimited_snap_view_time": { - "name": "Temps de visionnage des Snaps illimités", - "description": "Supprimer le temps limité de visionnage des Snaps" - }, - "prevent_sending_messages": { - "name": "Empêcher l'envoi des messages", - "description": "Empêche l'envoi de certains types de message" - }, - "anonymous_story_view": { - "name": "Anonymiser le visionnage des stories", - "description": "Empêche n'importe qui de savoir que vous avez vu leur story" - }, - "hide_typing_notification": { - "name": "Masquer la notification \"En train d'écrire\"", - "description": "Empêche la notification \"En train d'écrire\" d'apparaître" - }, - "save_folder": { - "name": "Dossier d'enregistrement", - "description": "L'emplacement où les médias sont sauvegardés" - }, - "auto_download_options": { - "name": "Options de téléchargement automatique", - "description": "Sélectionner les médias à télécharger automatiquement" - }, - "download_options": { - "name": "Options de téléchargement", - "description": "Spécifier le format de l'emplacement de fichier" - }, - "chat_download_context_menu": { - "name": "Menu de téléchargement du chat", - "description": "Activer le menu contextuel de téléchargement du Chat" - }, - "gallery_media_send_override": { - "name": "Remplacement de l'envoi des médias de la galerie", - "description": "Remplacement de l'envoi des médias depuis la galerie" - }, - "auto_save_messages": { - "name": "Enregistrement automatique des messages", - "description": "Sélectionner les types de messages à enregistrer automatiquement" - }, - "force_media_source_quality": { - "name": "Forcer la qualité source des médias", - "description": "Remplace la qualité source des médias" - }, - "download_logging": { - "name": "Télécharger les logs", - "description": "Afficher une notification bulle lorsque le média est en téléchargement" - }, - "enable_friend_feed_menu_bar": { - "name": "Barre du menu du fil d'ami", - "description": "Activer le nouveau menu en barre du fil d'ami" - }, - "friend_feed_menu_buttons": { - "name": "Boutons du menu du fil d'ami", - "description": "Sélectionner les boutons à afficher dans la barre de menu du fil d'amis" - }, - "friend_feed_menu_buttons_position": { - "name": "Position des boutons du menu du fil d'ami", - "description": "La position des boutons du menu du fil des amis" - }, - "hide_ui_elements": { - "name": "Masquer les éléments de l'interface", - "description": "Sélectionner quels éléments de l'interface à masquer" - }, - "hide_story_section": { - "name": "Masquer la section Story", - "description": "Masquer certains éléments visuels affichés dans la section des story" - }, - "story_viewer_override": { - "name": "Remplacement de la visionneuse des story", - "description": "Active certaines fonctionnalités cachées par Snapchat" - }, - "streak_expiration_info": { - "name": "Infos sur l'expiration des flammes", - "description": "Affiche les informations sur l'expiration à côté des flammes" - }, - "disable_snap_splitting": { - "name": "Désactiver le fractionnement des Snaps", - "description": "Empêche le fractionnement des snaps en plusieurs parties" - }, - "disable_video_length_restriction": { - "name": "Désactiver la restriction de la durée des vidéos", - "description": "Désactive les restrictions de la durée des vidéos" - }, - "snapchat_plus": { - "name": "Snapchat Plus", - "description": "Active les fonctionnalités de Snapchat Plus" - }, - "new_map_ui": { - "name": "Nouvelle interface de la carte", - "description": "Active la nouvelle interface de la carte" - }, - "location_spoof": { - "name": "Changer sa localisation sur la Snapmap", - "description": "Change votre localisation sur la Snapmap" - }, - "message_preview_length": { - "name": "Longueur de la prévisualisation du message", - "description": "Spécifier le nombre de messages à prévisualiser" - }, - "unlimited_conversation_pinning": { - "name": "Épinglage illimité de conversation", - "description": "Active la possibilité d'épingler de façon illimitée vos conversations" - }, - "disable_spotlight": { - "name": "Désactiver Spotlight", - "description": "Désactive la page Spotlight" - }, - "enable_app_appearance": { - "name": "Activer les réglages d'apparence de l'application", - "description": "Active les réglages masqués d'apparence de l'application" - }, - "startup_page_override": { - "name": "Remplacer la page de démarrage", - "description": "Remplace la page de démarrage" - }, - "disable_google_play_dialogs": { - "name": "Désactiver la boite de dialogue des services Google Play", - "description": "Empêcher les boites de dialogues des services de disponibilité de Google Play de s'afficher" - }, - "auto_updater": { - "name": "MàJ automatisée", - "description": "L'intervalle de vérification des Mise à jours" - }, - "disable_camera": { - "name": "Désactiver la caméra", - "description": "Empêche Snapchat d'utiliser la caméra" - }, - "immersive_camera_preview": { - "name": "Aperçu de la caméra immersif", - "description": "Empêche Snapchat de recadrer l'aperçu de l'appareil photo" - }, - "preview_resolution": { - "name": "Résolution de l'aperçu", - "description": "Remplace la résolution de l'aperçu de la caméra" - }, - "picture_resolution": { - "name": "Résolution de la photo", - "description": "Remplace la résolution de la photo" - }, - "force_highest_frame_rate": { - "name": "Forcer le taux de rafraîchissement maximal", - "description": "Force le taux de rafraîchissement le plus élevé possible" - }, - "force_camera_source_encoding": { - "name": "Forcer l'encodage source de la caméra", - "description": "Forcer l'encodage source de la caméra" - }, - "app_passcode": { - "name": "Créer un code pour verrouiller l'application", - "description": "Définit un code pour verrouiller l'application" - }, - "app_lock_on_resume": { - "name": "Verrouillage de l'application une fois de retour", - "description": "Verrouille l'application lorsqu'elle est réouverte" - }, - "infinite_story_boost": { - "name": "Booster de story infini", - "description": "Boost à l'infini votre story" - }, - "meo_passcode_bypass": { - "name": "Contournement du code d'accès de My Eyes Only", - "description": "Contourne le code d'accès de My Eyes Only\nCela ne fonctionnera uniquement que si vous avez déjà déverrouillé My Eyes Only auparavant" - }, - "amoled_dark_mode": { - "name": "Mode sombre AMOLED", - "description": "Activer le mode sombre pour les écrans AMOLED\nAssurez-vous d'avoir bien activé le mode sombre de Snapchat" - }, - "unlimited_multi_snap": { - "name": "Multi Snap illimité", - "description": "Vous permet d'avoir un nombre illimité de multi snap" - }, - "device_spoof": { - "name": "Falsifier les valeures de l'appareil", - "description": "Falsifie les valeures de l'appareil" - }, - "device_fingerprint": { - "name": "Empreinte numérique de l’appareil", - "description": "Falsifie l'empreinte numérique de l'appareil" + + "features": { + "notices": { + "unstable": "\u26A0 Instable", + "ban_risk": "\u26A0 Cette fonctionnalité peut entraîner un bannissement", + "internal_behavior": "\u26A0 Cette fonctionnalité peut causer des comportements internes inattendus" + }, + "properties": { + "downloader": { + "name": "Téléchargements", + "description": "Télécharger les médias de Snapchat", + "properties": { + "save_folder": { + "name": "Dossier de sauvegarde", + "description": "Sélectionnes le dossier dans lequel tous les médias doivent être téléchargés" + }, + "auto_download_sources": { + "name": "Sources de téléchargement automatique", + "description": "Sélectionnez les sources à télécharger automatiquement" + }, + "prevent_self_auto_download": { + "name": "Empêcher l'auto téléchargement de soi-même", + "description": "Empêche le téléchargement automatique de vos propres medias" + }, + "path_format": { + "name": "Format du chemin du fichier", + "description": "Spécifie le format du chemin du fichier" + }, + "allow_duplicate": { + "name": "Autoriser les doublons", + "description": "Autorise le même média à être téléchargé plusieurs fois" + }, + "merge_overlays": { + "name": "Fusionner les snaps contenant des superpositions", + "description": "Combine le texte et le média d'un Snap dans un seul fichier" + }, + "force_image_format": { + "name": "Forcer le format d'image", + "description": "Force les images à être enregistrées dans un format spécifié" + }, + "force_voice_note_format": { + "name": "Forcer le format des vocaux", + "description": "Force les vocaux à être enregistrés dans un format spécifié" + }, + "download_profile_pictures": { + "name": "Télécharger les photos de profil", + "description": "Permets de télécharger les photos de profil depuis la page de profil" + }, + "chat_download_context_menu": { + "name": "Menu contextuel de téléchargement de chat", + "description": "Permets de télécharger des médias d'une conversation en effectuant un appui long sur le message" + }, + "ffmpeg_options": { + "name": "Options de FFmpeg", + "description": "Spécifie les options de FFmpeg", + "properties": { + "threads": { + "name": "Threads", + "description": "Le nombre de threads à utiliser pour le traitement" + }, + "preset": { + "name": "Pré-réglages", + "description": "Défini la vitesse de traitement" + }, + "constant_rate_factor": { + "name": "Facteur de taux constant", + "description": "Défini le facteur de taux constant pour l'encodeur vidéo\nDe 0 à 51 pour libx264" + }, + "video_bitrate": { + "name": "Débit vidéo", + "description": "Défini le débit vidéo (en kbps)" + }, + "audio_bitrate": { + "name": "Débit audio", + "description": "Défini le débit audio (en kbps)" + }, + "custom_video_codec": { + "name": "Codec vidéo personnalisé", + "description": "Défini un codec vidéo personnalisé (par exemple libx264)" + }, + "custom_audio_codec": { + "name": "Codec audio personnalisé", + "description": "Défini un codec audio personnalisé (par exemple aac)" + } + } + }, + "logging": { + "name": "Journalisation", + "description": "Affiche des indications éphémères lorsque les médias sont téléchargés" + } + } + }, + "user_interface": { + "name": "Interface utilisateur", + "description": "Change l'apparence de Snapchat", + "properties": { + "enable_app_appearance": { + "name": "Activer l'apparence de l'application", + "description": "Active le paramètre d'apparence de l'application caché\nPeut ne pas être nécessaire sur les nouvelles versions de Snapchat" + }, + "amoled_dark_mode": { + "name": "Mode sombre AMOLED", + "description": "Active le mode sombre AMOLED\nAssurez-vous que le mode sombre de Snapchat est activé" + }, + "map_friend_nametags": { + "name": "Amélioration des nametags d'amis sur la carte", + "description": "Améliore les nametags des amis sur la Snapmap" + }, + "streak_expiration_info": { + "name": "Afficher les informations d'expiration des flammes", + "description": "Affiche un chronomètre d'expiration des flammes à côté du compteur de flammes" + }, + "hide_story_sections": { + "name": "Masquer les sections des stories", + "description": "Masque certains éléments de l'interface utilisateur affichés dans la section des stories" + }, + "hide_ui_components": { + "name": "Masquer les composants de l'interface utilisateur", + "description": "Sélectionne les composants de l'interface utilisateur à masquer" + }, + "2d_bitmoji_selfie": { + "name": "Selfie Bitmoji 2D", + "description": "Restaure les selfies Bitmoji 2D des anciennes versions de Snapchat\nVous devrez peut-être nettoyer le cache de Snapchat pour que cela prenne effet" + }, + "disable_spotlight": { + "name": "Désactiver Spotlight", + "description": "Désactive la page Spotlight" + }, + "startup_tab": { + "name": "Onglet de démarrage", + "description": "Change l'onglet qui s'ouvre au démarrage" + }, + "story_viewer_override": { + "name": "Remplacement du visionneur de story", + "description": "Active certaines fonctionnalités que Snapchat a cachées" + }, + "friend_feed_menu_buttons": { + "name": "Boutons du menu contextuel des amis", + "description": "Séléctionne les boutons à afficher dans le menu contextuel des amis" + }, + "friend_feed_menu_position": { + "name": "Position du menu contextuel des amis", + "description": "La position du menu contextuel des amis" + } + } + }, + "messaging": { + "name": "Messagerie", + "description": "Change la façon dont vous interagissez avec vos amis", + "properties": { + "anonymous_story_viewing": { + "name": "Visionnage anonyme des stories", + "description": "Empêche quiconque de savoir que vous avez vu leur story" + }, + "hide_bitmoji_presence": { + "name": "Masquer la présence Bitmoji", + "description": "Empêche votre Bitmoji de s'afficher pendant que vous êtes dans une conversation" + }, + "hide_typing_notifications": { + "name": "Masquer les notifications de saisie", + "description": "Empêche quiconque de savoir que vous êtes en train de taper un message" + }, + "unlimited_snap_view_time": { + "name": "Temps de visionnage illimité", + "description": "Supprime la limite de temps pour visionner les Snaps" + }, + "disable_replay_in_ff": { + "name": "Désactiver le revisionnage dans le flux d'amis", + "description": "Désactive la possibilité de revisionner avec un appui long depuis le flux d'amis" + }, + "prevent_message_sending": { + "name": "Empêcher l'envoi de messages", + "description": "Empêche l'envoi de certains types de messages" + }, + "better_notifications": { + "name": "Notifications améliorées", + "description": "Ajoute plus d'informations dans les notifications reçues" + }, + "notification_blacklist": { + "name": "Liste noire des notifications", + "description": "Sélectionnez les notifications qui doivent être bloquées" + }, + "message_logger": { + "name": "Journal des messages", + "description": "Empêche les messages d'être supprimés par l'envoyeur" + }, + "auto_save_messages_in_conversations": { + "name": "Sauvegarde automatique des messages", + "description": "Sauvegarde automatiquement chaque message dans les conversations" + }, + "gallery_media_send_override": { + "name": "Remplacement de l'envoi de médias de la galerie", + "description": "Modifie le type de média lors de l'envoi depuis la galerie" + }, + "message_preview_length": { + "name": "Longueur de l'aperçu des messages", + "description": "Spécifie le nombre de messages à prévisualiser" + } + } + }, + "global": { + "name": "Global", + "description": "Change les paramètres globaux de Snapchat", + "properties": { + "snapchat_plus": { + "name": "Snapchat Plus", + "description": "Active les fonctionnalités de Snapchat Plus\nCertaines fonctionnalités côté serveur peuvent ne pas fonctionner" + }, + "disable_metrics": { + "name": "Désactiver les métriques", + "description": "Bloque l'envoi de données analytiques spécifiques à Snapchat" + }, + "block_ads": { + "name": "Bloquer les publicités", + "description": "Empêche l'affichage des publicités" + }, + "disable_video_length_restrictions": { + "name": "Désactiver les restrictions de durée des vidéos", + "description": "Désactive la restriction de durée maximale des vidéos de Snapchat" + }, + "disable_google_play_dialogs": { + "name": "Désactiver les pop-up Google Play", + "description": "Empêche l'affichage des pop-up de disponibilité des services Google Play" + }, + "force_media_source_quality": { + "name": "Forcer la qualité originale des médias", + "description": "Force la qualité des médias de Snapchat à la valeur spécifiée" + }, + "disable_snap_splitting": { + "name": "Désactiver la division des Snaps", + "description": "Empêche les Snaps d'être divisés en plusieurs parties\nLes photos que vous envoyez se transformeront en vidéos" + } + } + }, + "rules": { + "name": "Règles", + "description": "Gérez les fonctionnalités automatiques pour chaque personne" + }, + "camera": { + "name": "Caméra", + "description": "Ajustez les bons paramètres pour le snap parfait", + "properties": { + "disable_camera": { + "name": "Désactiver la caméra", + "description": "Empêche Snapchat d'utiliser les caméras disponibles sur votre appareil" + }, + "immersive_camera_preview": { + "name": "Aperçu immersif de la caméra", + "description": "Empêche Snapchat de recadrer l'aperçu de la caméra\nCela peut provoquer des scintillements de la caméra sur certains appareils" + }, + "override_preview_resolution": { + "name": "Remplacement de la résolution de l'aperçu de la caméra", + "description": "Remplace la résolution de l'aperçu de la caméra" + }, + "override_picture_resolution": { + "name": "Remplacement de la résolution de l'image", + "description": "Remplace la résolution de l'image" + }, + "custom_frame_rate": { + "name": "Taux d'image par seconde personnalisé", + "description": "Remplace le taux d'image par seconde de la caméra" + }, + "force_camera_source_encoding": { + "name": "Forcer l'encodage de la source de la caméra", + "description": "Force l'encodage de la source de la caméra" + } + } + }, + "streaks_reminder": { + "name": "Rappels des flammes", + "description": "Vous rappelle périodiquement vos flammes", + "properties": { + "interval": { + "name": "Intervalle", + "description": "L'intervalle entre chaque rappel (heures)" + }, + "remaining_hours": { + "name": "Heures restantes", + "description": "Le temps restant avant que la notification ne s'affiche" + }, + "group_notifications": { + "name": "Regrouper les notifications", + "description": "Regroupe les notifications en une seule" + } + } + }, + "experimental": { + "name": "Expérimental", + "description": "Active les fonctionnalités expérimentales", + "properties": { + "native_hooks": { + "name": "Hooks natifs", + "description": "Fonctionnalités non sûres qui se greffe au code natif de Snapchat", + "properties": { + "disable_bitmoji": { + "name": "Désactiver les Bitmoji", + "description": "Désactive les Bitmoji de profil d'amis" + }, + "fix_gallery_media_override": { + "name": "Correction de l'envoi de médias de la galerie", + "description": "Corrige divers problèmes avec la fonctionnalité d'envoi de médias de la galerie (par exemple, enregistrer les Snaps dans le chat)" + } + } + }, + "spoof": { + "name": "Spoof", + "description": "Spoof diverses informations vous concernant", + "properties": { + "location": { + "name": "Localisation", + "description": "Spoof votre localisation", + "properties": { + "location_latitude": { + "name": "Latitude", + "description": "La latitude de la localisation" + }, + "location_longitude": { + "name": "Longitude", + "description": "La longitude de la localisation" + } + } + }, + "device": { + "name": "Appareil", + "description": "Spoof diverses informations sur votre appareil", + "properties": { + "fingerprint": { + "name": "Empreinte de l'appareil", + "description": "Spoof l'empreinte de votre appareil" + }, + "android_id": { + "name": "Android ID", + "description": "Spoof l'ID Android de votre appareil" + }, + "installer_package_name": { + "name": "Nom du package de l'installateur", + "description": "Spoof le nom du package de l'installateur" + }, + "debug_flag": { + "name": "Debug Flag", + "description": "Makes Snapchat debuggable" + }, + "mock_location": { + "name": "Mock location", + "description": "Spoofs the Mock Location device state" + }, + "split_classloader": { + "name": "Split Classloader", + "description": "Spoofs splitClassloader\nRequested by org.chromium.base.JNIUtils" + } + } + } + } + }, + "app_passcode": { + "name": "Code d'accès à l'application", + "description": "Défini un code d'accès à l'application" + }, + "app_lock_on_resume": { + "name": "Verrouillage de l'application à la reprise", + "description": "Verrouille l'application lorsque vous revenez à Snapchat" + }, + "infinite_story_boost": { + "name": "Boost infini des stories", + "description": "Contourne le délai de limite de boost de story" + }, + "meo_passcode_bypass": { + "name": "Contournement du code d'accès My Eyes Only", + "description": "Contourne le code d'accès My Eyes Only\nCela ne fonctionnera que si le code d'accès a été entré correctement auparavant" + }, + "unlimited_multi_snap": { + "name": "Multi Snap illimité", + "description": "Vous permet de prendre un nombre illimité de Multi Snaps" + }, + "no_friend_score_delay": { + "name": "Pas de délai de snap score d'ami", + "description": "Supprime le délai lors de la visualisation du snap score d'un ami" + }, + "add_friend_source_spoof": { + "name": "Spoof de la source d'ajout d'ami", + "description": "Spoof la source d'une demande d'ami" + } + } + } }, - "android_id": { - "name": "Identifiant Android", - "description": "Falsifie l'identifiant Android" - } - }, - "option": { - "property": { + "options": { "better_notifications": { - "chat": "Afficher les messages textuels", - "snap": "Afficher les médias", + "chat": "Afficher les messages de chat", + "snap": "Afficher les médias de Snap", "reply_button": "Ajouter un bouton de réponse", - "download_button": "Ajouter un boutton téléchargement" + "download_button": "Ajoouter un bouton de téléchargement", + "group": "Grouper les notifications" }, "friend_feed_menu_buttons": { - "auto_download_blacklist": "⬇️ Liste noire des téléchargements automatiques", - "anti_auto_save": "💬 Empêcher l'enregistrement automatique des messages", - "stealth_mode": "👻 Mode furtif", - "conversation_info": "👤 Infos de la Conversation" + "auto_download": "\u2B07\uFE0F Téléchargement automatique", + "auto_save": "\uD83D\uDCAC Sauvegarde automatique", + "stealth": "\uD83D\uDC7B Mode furtif", + "conversation_info": "\uD83D\uDC64 Informations de la conversation" }, - "download_options": { - "allow_duplicate": "Autoriser les téléchargements dupliqués", - "create_user_folder": "Créer un dossier pour chaque utilisateur", - "append_hash": "Ajouter une empreinte unique au nom du fichier", + "path_format": { + "create_author_folder": "Créer un dossier pour chaque auteur", + "create_source_folder": "Créer un dossier pour chaque type de source de média", + "append_hash": "Ajouter un hash unique au nom du fichier", + "append_source": "Ajouter la source du média au nom du fichier", "append_username": "Ajouter le nom d'utilisateur au nom du fichier", - "append_date_time": "Ajouter la date ainsi que l'heure au nom du fichier", - "append_type": "Ajouter le type de média au nom du fichier", - "merge_overlay": "Fusion des superpositions des Snaps" + "append_date_time": "Ajouter la date et l'heure au nom du fichier" }, - "auto_download_options": { - "friend_snaps": "Snaps des amis", - "friend_stories": "Stories des amis", + "auto_download_sources": { + "friend_snaps": "Snaps d'amis", + "friend_stories": "Stories d'amis", "public_stories": "Stories publiques", "spotlight": "Spotlight" }, - "download_logging": { - "started": "Démarré", + "logging": { + "started": "Au lancement", "success": "Succès", - "progress": "Progression", + "progress": "En cours", "failure": "Échec" }, - "auto_save_messages": { - "NOTE": "Message vocal", - "CHAT": "Message textuel", - "EXTERNAL_MEDIA": "Média externe", - "SNAP": "Snap", - "STICKER": "Autocollant" + "auto_save_messages_in_conversations": { + "NOTE": "Messages vocaux", + "CHAT": "Messages de chat", + "EXTERNAL_MEDIA": "Médias externes", + "SNAP": "Snaps", + "STICKER": "Stickers" }, "notifications": { - "chat_screenshot": "Capture d'écran", - "chat_screen_record": "Enregistrement vidéo de l'écran", + "chat_screenshot": "Capture d'écran de chat", + "chat_screen_record": "Enregistrement d'écran de chat", "camera_roll_save": "Sauvegarde de la pellicule", - "chat": "Discussion", - "chat_reply": "Réponse au chat", + "chat": "Chat", + "chat_reply": "Réponse de chat", "snap": "Snap", - "typing": "Saisie en cours", + "typing": "Saisie", "stories": "Stories", "initiate_audio": "Appel audio entrant", "abandon_audio": "Appel audio manqué", @@ -307,78 +549,77 @@ "abandon_video": "Appel vidéo manqué" }, "gallery_media_send_override": { - "ORIGINAL": "Originale", + "ORIGINAL": "Contenu original", "NOTE": "Message vocal", "SNAP": "Snap", - "LIVE_SNAP": "Snap avec audio" - }, - "hide_ui_elements": { - "remove_call_buttons": "Supprimer le bouton d'appel", - "remove_cognac_button": "Supprimer le bouton Cognac", - "remove_live_location_share_button": "Supprimer le bouton de partage de la localisation en direct", - "remove_stickers_button": "Supprimer le bouton des autocollants", - "remove_voice_record_button": "Supprimer le bouton d'enregistrement de la voix", - "remove_camera_borders": "Supprimer les bordures de la caméra" + "SAVABLE_SNAP": "Snap sauvegardable" }, - "auto_updater": { - "DISABLED": "Désactivé", - "EVERY_LAUNCH": "À chaque lancement", - "DAILY": "Journalière", - "WEEKLY": "Hebdomadaire" + "hide_ui_components": { + "hide_call_buttons": "Supprimer les boutons d'appel", + "hide_cognac_button": "Supprimer le bouton Cognac", + "hide_live_location_share_button": "Supprimer le bouton de partage de la position en direct", + "hide_stickers_button": "Supprimer le bouton des stickers", + "hide_voice_record_button": "Supprimer le bouton d'enregistrement vocal" }, "story_viewer_override": { "OFF": "Désactivé", - "DISCOVER_PLAYBACK_SEEKBAR": "Activer la barre de lecture des Stories", - "VERTICAL_STORY_VIEWER": "Activer l'affichage verticale des Stories" + "DISCOVER_PLAYBACK_SEEKBAR": "Activer la barre de lecture dans Discover", + "VERTICAL_STORY_VIEWER": "Activer le visionneur de story vertical" }, - "hide_story_section": { - "hide_friend_suggestions": "Masquer les suggestions d'ami", + "hide_story_sections": { + "hide_friend_suggestions": "Masquer la section des suggestions d'amis", "hide_friends": "Masquer la section des amis", - "hide_following": "Masquer la section suivante", + "hide_following": "Masquer la section des abonnements", "hide_for_you": "Masquer la section Pour vous" }, - "startup_page_override": { - "OFF": "Inactif", - "ngs_map_icon_container": "Carte", - "ngs_chat_icon_container": "Discussion", + "startup_tab": { + "ngs_map_icon_container": "Snapmap", + "ngs_chat_icon_container": "Chat", "ngs_camera_icon_container": "Caméra", "ngs_community_icon_container": "Communauté / Stories", "ngs_spotlight_icon_container": "Spotlight", - "ngs_search_icon_container": "Rechercher" + "ngs_search_icon_container": "Recherche" + }, + "add_friend_source_spoof": { + "added_by_username": "Par nom d'utilisateur", + "added_by_mention": "Par mention", + "added_by_group_chat": "Par group d'amis", + "added_by_qr_code": "Par QR code", + "added_by_community": "Par communauté" } } }, + "friend_menu_option": { - "preview": "Aperçu", - "stealth_mode": "Mode furtif", - "auto_download_blacklist": "Liste noire des téléchargements automatiques", - "anti_auto_save": "Empêcher l'enregistrement automatique" - }, - "message_context_menu_option": { - "download": "Télécharger", "preview": "Aperçu" }, + "chat_action_menu": { "preview_button": "Aperçu", "download_button": "Télécharger", "delete_logged_message_button": "Supprimer le message enregistré" }, + "opera_context_menu": { "download": "Télécharger" }, + "modal_option": { "profile_info": "Informations du profil", "close": "Fermer" }, + "gallery_media_send_override": { "multiple_media_toast": "Vous ne pouvez envoyer qu'un seul média à la fois" }, + "conversation_preview": { - "streak_expiration": "expire dans {day} jour(s) {hour} heure(s) {minute} minute(s)", - "total_messages": "Total des messages envoyés/reçus: {count}", - "title": "Aperçu", + "streak_expiration": "Expire dans {day} jours {hour} heures {minute} minutes", + "total_messages": "Total des messages envoyés/reçus : {count}", + "title": "Aperçu de la conversation", "unknown_user": "Utilisateur inconnu" }, + "profile_info": { "title": "Informations du profil", "username": "Nom d'utilisateur", @@ -386,83 +627,58 @@ "added_date": "Date d'ajout", "birthday": "Anniversaire : {day} {month}" }, - "auto_updater": { - "no_update_available": "Aucune mise à jour disponible !", - "dialog_title": "Nouvelle mise à jour disponible !", - "dialog_message": "Une nouvelle mise à jour pour SnapEnhance est disponible ! ({version})\n\n{body}", - "dialog_positive_button": "Télécharger & installer", - "dialog_negative_button": "Annuler", - "downloading_toast": "Téléchargement de la mise à jour...", - "download_manager_notification_title": "Téléchargement de l'APK de SnapEnhance..." - }, + "chat_export": { - "select_export_format": "Sélectionner le format d'exportation", - "select_media_type": "Sélectionner les types de médias à exporter", - "select_conversation": "Sélectionner une conversation à exporter", + "select_export_format": "Sélectionnez le format d'exportation", + "select_media_type": "Sélectionnez le type de média", + "select_amount_of_messages": "Sélectionnez la quantité de messages à exporter (laissez vide pour tout)", + "select_conversation": "Sélectionnez la conversation à exporter", "dialog_negative_button": "Annuler", - "dialog_neutral_button": "Tout exporter", + "dialog_neutral_button": "Exporter tout", "dialog_positive_button": "Exporter", "exported_to": "Exporté vers {path}", - "exporting_chats": "Exportation des chats...", + "exporting_chats": "Exportation des conversations...", "processing_chats": "Traitement de {amount} conversations...", - "export_fail": "Échec de l'export de la conversation {conversation}", - "writing_output": "Écriture...", - "finished": "Terminé! Vous pouvez maintenant fermer cette fenêtre.", - "no_messages_found": "Aucun message trouvé!", + "export_fail": "Échec de l'exportation de la conversation {conversation}", + "writing_output": "Écriture du fichier de sortie...", + "finished": "Terminé ! Vous pouvez maintenant fermer cette fenêtre.", + "no_messages_found": "Aucun message trouvé", "exporting_message": "Exportation de {conversation}..." }, + "button": { - "ok": "Ok", + "ok": "OK", "positive": "Oui", "negative": "Non", "cancel": "Annuler", - "open": "Ouvrir" + "open": "Ouvrir", + "download": "Télécharger" }, - "download_manager_activity": { - "remove_all_title": "Supprimer tous les téléchargements", - "remove_all_text": "Êtes-vous sûr de que vouloir faire cela ?", - "remove_all": "Tout supprimer", - "no_downloads": "Aucun téléchargements", - "cancel": "Annuler", - "file_not_found_toast": "Le fichier n'existe pas!", - "category": { - "all_category": "Tout", - "pending_category": "En attente", - "snap_category": "Snaps", - "story_category": "Stories", - "spotlight_category": "Spotlight" - }, - "debug_settings": "Réglages de débogage", - "debug_settings_page": { - "clear_file_title": "Effacer le fichier {file_name}", - "clear_file_confirmation": "Êtes vous sûr de vouloir effacer le fichier {file_name}?", - "clear_cache_title": "Vider le cache", - "reset_all_title": "Réinitialiser tous les réglages", - "reset_all_confirmation": "Êtes vous sûr de vouloir réinitialiser tous les réglages?", - "success_toast": "Succèss!", - "device_spoofer": "Falsification de l'appareil" - } + + "profile_picture_downloader": { + "button": "Télécharger la photo de profil", + "title": "Télécharger la photo de profil", + "avatar_option": "Avatar", + "background_option": "Arrière-plan" }, + "download_processor": { "download_started_toast": "Téléchargement démarré", - "unsupported_content_type_toast": "Type de contenu non supporté!", - "failed_no_longer_available_toast": "Média plus disponible", - "already_queued_toast": "Média déjà en file d'attente!", - "already_downloaded_toast": "Média déjà téléchargé!", - "saved_toast": "Enregistré dans {path}", - "download_toast": "Téléchargement {path}...", + "unsupported_content_type_toast": "Type de contenu non pris en charge", + "failed_no_longer_available_toast": "Le média n'est plus disponible", + "already_queued_toast": "Média déjà en file d'attente", + "already_downloaded_toast": "Média déjà téléchargé", + "saved_toast": "Sauvegardé dans {path}", + "download_toast": "Téléchargement de {path}...", "processing_toast": "Traitement de {path}...", "failed_generic_toast": "Échec du téléchargement", - "failed_to_create_preview_toast": "Échec de création de l'aperçu", + "failed_to_create_preview_toast": "Échec de la création de l'aperçu", "failed_processing_toast": "Échec du traitement {error}", "failed_gallery_toast": "Échec de l'enregistrement dans la galerie {error}" }, - "config_activity": { - "title": "Réglages de SnapEnhance", - "selected_text": "{count} élément(s) sélectionné(s)", - "invalid_number_toast": "Numéro invalide!" - }, - "spoof_activity": { - "title": "Paramètres du Falsificateur" + + "streaks_reminder": { + "notification_title": "Flammes", + "notification_text": "Vous allez perdre vos flammes avec {friend} dans {hoursLeft} heures" } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt index f82768e3a..8d7e4c084 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigObjects.kt @@ -83,7 +83,7 @@ data class PropertyKey( fun propertyOption(translation: LocaleWrapper, key: String): String { if (key == "null") { - return translation[params.disabledKey ?: "manager.features.disabled"] + return translation[params.disabledKey ?: "manager.sections.features.disabled"] } return if (!params.flags.contains(ConfigFlag.NO_TRANSLATE)) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt index c32fc8950..a7e0672ff 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt @@ -70,7 +70,9 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI context.runOnUiThread { ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) - .setItems(typeNames.values.toTypedArray()) { dialog, which -> + .setItems(typeNames.values.map { + context.translation["features.options.gallery_media_send_override.$it"] + }.toTypedArray()) { dialog, which -> dialog.dismiss() val overrideType = typeNames.keys.toTypedArray()[which] From 7924d5a445180d2786cfc8ef9b6b1c6fc203b701 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 6 Sep 2023 23:02:11 +0200 Subject: [PATCH 037/274] fix(media_downloader): two images overlay merge --- .../me/rhunk/snapenhance/download/DownloadProcessor.kt | 9 +++++---- .../snapenhance/core/download/data/DownloadRequest.kt | 1 + .../features/impl/downloader/MediaDownloader.kt | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 3a66738ba..21840b304 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -380,14 +380,15 @@ class DownloadProcessor ( if (shouldMergeOverlay) { assert(downloadedMedias.size == 2) - val media = downloadedMedias.values.first { it.fileType.isVideo } - val overlayMedia = downloadedMedias.values.first { it.fileType.isImage } + //TODO: convert "mp4 images" into real images + val media = downloadedMedias.entries.first { !it.key.isOverlay }.value + val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value val renamedMedia = renameFromFileType(media.file, media.fileType) val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) - val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) + val mergedOverlay: File = File.createTempFile("merged", ".mp4") runCatching { - callbackOnProgress(translation.format("download_toast", "path" to media.file.nameWithoutExtension)) + callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension)) downloadObjectObject.downloadStage = DownloadStage.MERGING ffmpegProcessor.execute(FFMpegProcessor.Request( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt index 1d573a0d1..5105e77b4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -7,6 +7,7 @@ data class InputMedia( val type: DownloadMediaType, val encryption: MediaEncryptionKeyPair? = null, val messageContentType: String? = null, + val isOverlay: Boolean = false, ) class DownloadRequest( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index cf0dbdc98..d80548f3b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -199,7 +199,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp overlay = InputMedia( overlayReference, DownloadMediaType.fromUri(Uri.parse(overlayReference)), - overlay.encryption?.toKeyPair() + overlay.encryption?.toKeyPair(), + isOverlay = true ) ) return @@ -372,7 +373,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaAuthor = storyName ).downloadDashMedia(playlistUrl, 0, null) } - setPositiveButton("Download") { dialog, which -> + setPositiveButton("Download") { _, _ -> val groups = mutableListOf>() var currentGroup = mutableListOf() var lastChapterIndex = -1 From 3926235d57ed602af4c82ab236b32c64bef62efb Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 6 Sep 2023 23:02:42 +0200 Subject: [PATCH 038/274] fix(chat_exporter): enter conversation callback bug --- .../me/rhunk/snapenhance/action/impl/ExportChatMessages.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt index d9cfdf19d..2ba55c501 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -216,8 +216,10 @@ class ExportChatMessages : AbstractAction() { val conversationId = friendFeedEntry.key!! val conversationName = friendFeedEntry.feedDisplayName ?: friendFeedEntry.friendDisplayName!!.split("|").lastOrNull() ?: "unknown" - conversationAction(true, conversationId, if (friendFeedEntry.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE") - + runCatching { + conversationAction(true, conversationId, if (friendFeedEntry.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE") + } + logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName)) val foundMessages = fetchMessagesPaginated(conversationId, Long.MAX_VALUE).toMutableList() From 03c33e03f63701e966309ad8e4dd20cb2dc4e038 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 6 Sep 2023 23:07:49 +0200 Subject: [PATCH 039/274] fix(media_downloader): prevent story self auto download --- .../snapenhance/features/impl/downloader/MediaDownloader.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index d80548f3b..167c757a2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -273,7 +273,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp conversationParticipants.firstOrNull { it != conversationMessage.senderId } } - if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && storyUserId == context.database.myUserId) return val author = context.database.getFriendInfo( if (storyUserId == null || storyUserId == "null") @@ -282,7 +281,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) ?: throw Exception("Friend not found in database") val authorName = author.usernameForSorting!! - if (!forceDownload && !canUseRule(author.userId!!)) return + if (!forceDownload) { + if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return + if (!canUseRule(author.userId!!)) return + } downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = paramMap["MEDIA_ID"].toString(), From 78c6b06f454702e7ce8fe5994873a78d80f27a3b Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 6 Sep 2023 23:30:29 +0200 Subject: [PATCH 040/274] fix(mapper): exclude CppProxy callback classes --- .../rhunk/snapenhance/features/impl/tweaks/AutoSave.kt | 10 +++++----- .../kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt index 8ea55d793..5845e852e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -39,12 +39,12 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, if (messageLogger.isMessageRemoved(conversationId.toString(), message.orderKey)) return if (message.messageState != MessageState.COMMITTED) return - val callback = CallbackBuilder(callbackClass) - .override("onError") { - context.log.warn("Error saving message $messageId") - }.build() - runCatching { + val callback = CallbackBuilder(callbackClass) + .override("onError") { + context.log.warn("Error saving message $messageId") + }.build() + updateMessageMethod.invoke( context.feature(Messaging::class).conversationManager, conversationId.instanceNonNull(), diff --git a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt index f5e2904e9..9b5dc4afa 100644 --- a/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt +++ b/mapper/src/main/kotlin/me/rhunk/snapmapper/impl/CallbackMapper.kt @@ -12,7 +12,10 @@ class CallbackMapper : AbstractClassMapper() { if (clazz.superclass == null) return@filter false val superclassName = clazz.getSuperClassName()!! - if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate")) || superclassName.endsWith("\$Callback")) return@filter false + if ((!superclassName.endsWith("Callback") && !superclassName.endsWith("Delegate")) + || superclassName.endsWith("\$Callback")) return@filter false + + if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false val superClass = context.getClass(clazz.superclass) ?: return@filter false !superClass.isFinal() From 3c09bf20c9bac7a214f4495a39adcb159dcba0f4 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 9 Sep 2023 09:15:40 +0200 Subject: [PATCH 041/274] fix(auto_save): error when posting a story --- .../me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt index 5845e852e..370486652 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -123,9 +123,10 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, { autoSaveFilter.isNotEmpty() } ) { val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() + val conversationUUID = messaging.openedConversationUUID ?: return@hook runCatching { fetchConversationWithMessagesPaginatedMethod.invoke( - messaging.conversationManager, messaging.openedConversationUUID!!.instanceNonNull(), + messaging.conversationManager, conversationUUID.instanceNonNull(), Long.MAX_VALUE, 10, callback From 167f93feca2d1993fc3cbf3e9f49cb14369c3632 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 9 Sep 2023 09:21:32 +0200 Subject: [PATCH 042/274] feat(snapchat_plus): hidden sc+ features (experimental) --- core/src/main/assets/lang/en_US.json | 4 ++++ .../core/config/impl/Experimental.kt | 1 + .../features/impl/tweaks/SnapchatPlus.kt | 21 +++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 0aa7fc266..bcdfd1b36 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -498,6 +498,10 @@ "add_friend_source_spoof": { "name": "Add Friend Source Spoof", "description": "Spoofs the source of a Friend Request" + }, + "hidden_snapchat_plus_features": { + "name": "Hidden Snapchat Plus Features", + "description": "Enables unreleased/beta Snapchat Plus features\nMight not work on older Snapchat versions" } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt index 247841921..fc196a106 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Experimental.kt @@ -12,6 +12,7 @@ class Experimental : ConfigContainer() { val meoPasscodeBypass = boolean("meo_passcode_bypass") val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.BAN_RISK)} val noFriendScoreDelay = boolean("no_friend_score_delay") + val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) } val addFriendSourceSpoof = unique("add_friend_source_spoof", "added_by_username", "added_by_mention", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt index 1efc14932..03bbf8bf4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SnapchatPlus.kt @@ -4,12 +4,13 @@ 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 SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { +class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.INIT_SYNC) { private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) - override fun asyncOnActivityCreate() { + override fun init() { if (!context.config.global.snapchatPlus.get()) return val subscriptionInfoClass = context.mappings.getMappedClass("SubscriptionInfoClass") @@ -24,5 +25,21 @@ class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.ACTIV param.setArg(2, originalSubscriptionTime) param.setArg(3, expirationTimeMillis) } + + if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) { + findClass("com.snap.plus.FeatureCatalog").methods.last { + !it.name.contains("init") && + it.parameterTypes.isNotEmpty() && + it.parameterTypes[0].name != "java.lang.Boolean" + }.hook(HookStage.BEFORE) { param -> + val instance = param.thisObject() + val firstArg = param.args()[0] + + instance::class.java.declaredFields.filter { it.type == firstArg::class.java }.forEach { + it.isAccessible = true + it.set(instance, firstArg) + } + } + } } } \ No newline at end of file From 01476ad820e745930f7656b9678d00834d10ab2c Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 9 Sep 2023 09:22:57 +0200 Subject: [PATCH 043/274] fix(streaks_reminder): notification streak icon --- .../kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt index 3d0d57aeb..fa79b5f2b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -85,9 +85,9 @@ class StreaksReminder( PendingIntent.FLAG_IMMUTABLE )) .apply { + setSmallIcon(R.drawable.streak_icon) bitmojiImage.drawable?.let { setLargeIcon(it.toBitmap()) - setSmallIcon(R.drawable.streak_icon) } } From 249569468ce871173a3f60c53a7aadc7f89e3cf8 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 9 Sep 2023 10:22:52 +0200 Subject: [PATCH 044/274] feat: bootstrap override - home tab - persistent app appearance --- core/src/main/assets/lang/en_US.json | 32 ++++++++--- .../core/config/impl/UserInterfaceTweaks.kt | 18 +++---- .../features/impl/ConfigurationOverride.kt | 1 - .../impl/ui/ClientBootstrapOverride.kt | 34 ++++++++++++ .../features/impl/ui/StartupPageOverride.kt | 54 ------------------- .../manager/impl/FeatureManager.kt | 4 +- 6 files changed, 68 insertions(+), 75 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/ClientBootstrapOverride.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/StartupPageOverride.kt diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index bcdfd1b36..800fcca42 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -213,6 +213,20 @@ "name": "AMOLED Dark Mode", "description": "Enables AMOLED dark mode\nMake sure Snapchats Dark mode is enabled" }, + "bootstrap_override": { + "name": "Bootstrap Override", + "description": "Overrides user interface bootstrap settings", + "properties": { + "app_appearance": { + "name": "App Appearance", + "description": "Sets a persistent App Appearance" + }, + "home_tab": { + "name": "Home Tab", + "description": "Overrides the startup tab when opening Snapchat" + } + } + }, "map_friend_nametags": { "name": "Enhanced Friend Map Nametags", "description": "Improves the Nametags of friends on the Snapmap" @@ -507,6 +521,10 @@ } }, "options": { + "app_appearance": { + "always_light": "Always Light", + "always_dark": "Always Dark" + }, "better_notifications": { "chat": "Show chat messages", "snap": "Show media", @@ -585,14 +603,12 @@ "hide_following": "Hide following section", "hide_for_you": "Hide For You section" }, - "startup_tab": { - "OFF": "Off", - "ngs_map_icon_container": "Map", - "ngs_chat_icon_container": "Chat", - "ngs_camera_icon_container": "Camera", - "ngs_community_icon_container": "Community / Stories", - "ngs_spotlight_icon_container": "Spotlight", - "ngs_search_icon_container": "Search" + "home_tab": { + "map": "Map", + "chat": "Chat", + "camera": "Camera", + "discover": "Discover", + "spotlight": "Spotlight" }, "add_friend_source_spoof": { "added_by_username": "By Username", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt index e8dcdf06e..990d16e76 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt @@ -3,9 +3,14 @@ package me.rhunk.snapenhance.core.config.impl import me.rhunk.snapenhance.core.config.ConfigContainer import me.rhunk.snapenhance.core.config.FeatureNotice import me.rhunk.snapenhance.core.messaging.MessagingRuleType +import me.rhunk.snapenhance.features.impl.ui.ClientBootstrapOverride class UserInterfaceTweaks : ConfigContainer() { - val enableAppAppearance = boolean("enable_app_appearance") + inner class BootstrapOverride : ConfigContainer() { + val appAppearance = unique("app_appearance", "always_light", "always_dark") + val homeTab = unique("home_tab", *ClientBootstrapOverride.tabs) { addNotices(FeatureNotice.UNSTABLE) } + } + val friendFeedMenuButtons = multiple( "friend_feed_menu_buttons","conversation_info", *MessagingRuleType.values().toList().filter { it.listMode }.map { it.key }.toTypedArray() ).apply { @@ -13,6 +18,7 @@ class UserInterfaceTweaks : ConfigContainer() { } val friendFeedMenuPosition = integer("friend_feed_menu_position", defaultValue = 1) val amoledDarkMode = boolean("amoled_dark_mode") { addNotices(FeatureNotice.UNSTABLE) } + val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) val mapFriendNameTags = boolean("map_friend_nametags") val streakExpirationInfo = boolean("streak_expiration_info") val hideStorySections = multiple("hide_story_sections", @@ -26,13 +32,5 @@ class UserInterfaceTweaks : ConfigContainer() { ) val ddBitmojiSelfie = boolean("2d_bitmoji_selfie") val disableSpotlight = boolean("disable_spotlight") - val startupTab = unique("startup_tab", - "ngs_map_icon_container", - "ngs_chat_icon_container", - "ngs_camera_icon_container", - "ngs_community_icon_container", - "ngs_spotlight_icon_container", - "ngs_search_icon_container" - ) { addNotices(FeatureNotice.INTERNAL_BEHAVIOR) } - val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") { addNotices(FeatureNotice.UNSTABLE) } + val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt index 983ed0b8b..c037872ee 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt @@ -26,7 +26,6 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea overrideProperty("DF_VOPERA_FOR_STORIES", { state == "VERTICAL_STORY_VIEWER" }, true) } - overrideProperty("SIG_APP_APPEARANCE_SETTING", { context.config.userInterface.enableAppAppearance.get() }, true) overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, false) overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, true) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/ClientBootstrapOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/ClientBootstrapOverride.kt new file mode 100644 index 000000000..876ecc0b8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/ClientBootstrapOverride.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.features.impl.ui + +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import java.io.File + + +class ClientBootstrapOverride : Feature("ClientBootstrapOverride", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + companion object { + val tabs = arrayOf("map", "chat", "camera", "discover", "spotlight") + } + + private val clientBootstrapFolder by lazy { File(context.androidContext.filesDir, "client-bootstrap") } + + private val appearanceStartupConfigFile by lazy { File(clientBootstrapFolder, "appearancestartupconfig") } + private val plusFile by lazy { File(clientBootstrapFolder, "plus") } + + override fun onActivityCreate() { + val bootstrapOverrideConfig = context.config.userInterface.bootstrapOverride + + bootstrapOverrideConfig.appAppearance.getNullable()?.also { appearance -> + val state = when (appearance) { + "always_light" -> 0 + "always_dark" -> 1 + else -> return@also + }.toByte() + appearanceStartupConfigFile.writeBytes(byteArrayOf(0, 0, 0, state)) + } + + bootstrapOverrideConfig.homeTab.getNullable()?.also { currentTab -> + plusFile.writeBytes(byteArrayOf(8, (tabs.indexOf(currentTab) + 1).toByte())) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/StartupPageOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/StartupPageOverride.kt deleted file mode 100644 index 4c4cc6aa3..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/StartupPageOverride.kt +++ /dev/null @@ -1,54 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -import android.annotation.SuppressLint -import android.os.Handler -import android.view.View -import android.widget.LinearLayout -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.eventbus.events.impl.AddViewEvent -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook - -@SuppressLint("DiscouragedApi") -class StartupPageOverride : Feature("StartupPageOverride", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private var ngsIcon: View? = null - /* - navbar icons: - ngs_map_icon_container - ngs_chat_icon_container - ngs_camera_icon_container - ngs_community_icon_container - ngs_spotlight_icon_container - ngs_search_icon_container - */ - - private fun clickNgsIcon() { - Handler(context.androidContext.mainLooper).postDelayed({ - ngsIcon?.callOnClick() - }, 300) - } - - override fun onActivityCreate() { - val ngsIconName = context.config.userInterface.startupTab.getNullable() ?: return - - context.androidContext.classLoader.loadClass("com.snap.mushroom.MainActivity").apply { - hook("onResume", HookStage.AFTER) { clickNgsIcon() } - } - - val ngsIconId = context.androidContext.resources.getIdentifier(ngsIconName, "id", Constants.SNAPCHAT_PACKAGE_NAME) - - lateinit var unhook: () -> Unit - - context.event.subscribe(AddViewEvent::class) { event -> - if (event.parent !is LinearLayout) return@subscribe - with(event.view) { - if (id == ngsIconId) { - ngsIcon = this - unhook() - } - } - }.also { unhook = it } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index fd7ef02e0..dd2ec57cf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -35,7 +35,7 @@ import me.rhunk.snapenhance.features.impl.tweaks.SendOverride import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime import me.rhunk.snapenhance.features.impl.ui.PinConversations -import me.rhunk.snapenhance.features.impl.ui.StartupPageOverride +import me.rhunk.snapenhance.features.impl.ui.ClientBootstrapOverride import me.rhunk.snapenhance.features.impl.ui.UITweaks import me.rhunk.snapenhance.manager.Manager import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector @@ -90,7 +90,7 @@ class FeatureManager(private val context: ModContext) : Manager { register(PinConversations::class) register(UnlimitedMultiSnap::class) register(DeviceSpooferHook::class) - register(StartupPageOverride::class) + register(ClientBootstrapOverride::class) register(GooglePlayServicesDialogs::class) register(NoFriendScoreDelay::class) register(ProfilePictureDownloader::class) From 7b5a411a5d9756da1f3ef7e7e8478253126ed88c Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:35:26 +0200 Subject: [PATCH 045/274] refactor: core package --- .../kotlin/me/rhunk/snapenhance/LogManager.kt | 1 + .../me/rhunk/snapenhance/bridge/BridgeService.kt | 4 ++-- .../snapenhance/download/DownloadProcessor.kt | 2 +- .../snapenhance/download/DownloadTaskManager.kt | 6 +++--- .../snapenhance/download/FFMpegProcessor.kt | 4 ++-- .../rhunk/snapenhance/messaging/ModDatabase.kt | 8 ++++---- .../snapenhance/messaging/StreaksReminder.kt | 2 +- .../ui/manager/sections/home/HomeSubSection.kt | 4 ++-- .../manager/sections/social/AddFriendDialog.kt | 2 +- .../ui/manager/sections/social/ScopeContent.kt | 2 +- .../ui/manager/sections/social/SocialSection.kt | 2 +- .../ui/setup/screens/impl/MappingsScreen.kt | 5 ----- .../ui/util/ActivityLauncherHelper.kt | 2 +- .../me/rhunk/snapenhance/EventDispatcher.kt | 6 +++--- .../kotlin/me/rhunk/snapenhance/ModContext.kt | 3 ++- .../kotlin/me/rhunk/snapenhance/SnapEnhance.kt | 3 ++- .../action/impl/ExportChatMessages.kt | 8 ++++---- .../me/rhunk/snapenhance/{ => core}/Logger.kt | 2 +- .../core/bridge/wrapper/LocaleWrapper.kt | 2 +- .../core/bridge/wrapper/MappingsWrapper.kt | 2 +- .../core/bridge/wrapper/MessageLoggerWrapper.kt | 2 +- .../snapenhance/core/database/DatabaseAccess.kt | 2 +- .../core/database/objects/ConversationMessage.kt | 10 +++++----- .../core/database/objects/FriendFeedEntry.kt | 8 ++++---- .../core/database/objects/FriendInfo.kt | 8 ++++---- .../core/database/objects/StoryEntry.kt | 4 ++-- .../database/objects/UserConversationLink.kt | 4 ++-- .../core/messaging/MessagingCoreObjects.kt | 2 +- .../{ => core}/util/CallbackBuilder.kt | 2 +- .../{ => core}/util/ReflectionHelper.kt | 2 +- .../{ => core}/util/SQLiteDatabaseHelper.kt | 4 ++-- .../{ => core}/util/SerializableDataObject.kt | 2 +- .../{ => core}/util/download/HttpServer.kt | 4 ++-- .../util/download/RemoteMediaResolver.kt | 4 ++-- .../{ => core}/util/export/MessageExporter.kt | 8 ++++---- .../util/ktx/AndroidCompatExtensions.kt | 2 +- .../{ => core}/util/ktx/DbCursorExt.kt | 2 +- .../{ => core}/util/ktx/XposedHelperExt.kt | 2 +- .../{ => core}/util/protobuf/ProtoEditor.kt | 2 +- .../{ => core}/util/protobuf/ProtoReader.kt | 2 +- .../{ => core}/util/protobuf/ProtoWriter.kt | 2 +- .../{ => core}/util/protobuf/WireType.kt | 2 +- .../{ => core}/util/snap/BitmojiSelfie.kt | 2 +- .../{ => core}/util/snap/EncryptionHelper.kt | 10 +++++++--- .../util/snap/MediaDownloaderHelper.kt | 6 +++--- .../{ => core}/util/snap/PreviewUtils.kt | 2 +- .../snap/SnapWidgetBroadcastReceiverHelper.kt | 2 +- .../kotlin/me/rhunk/snapenhance/data/FileType.kt | 2 +- .../me/rhunk/snapenhance/data/MessageSender.kt | 4 ++-- .../snapenhance/data/wrapper/AbstractWrapper.kt | 2 +- .../snapenhance/data/wrapper/impl/Message.kt | 2 +- .../data/wrapper/impl/MessageContent.kt | 4 ++-- .../data/wrapper/impl/MessageDescriptor.kt | 2 +- .../data/wrapper/impl/MessageDestinations.kt | 4 ++-- .../data/wrapper/impl/MessageMetadata.kt | 2 +- .../snapenhance/data/wrapper/impl/SnapUUID.kt | 2 +- .../data/wrapper/impl/UserIdToReaction.kt | 2 +- .../data/wrapper/impl/media/MediaInfo.kt | 2 +- .../data/wrapper/impl/media/opera/Layer.kt | 2 +- .../wrapper/impl/media/opera/LayerController.kt | 2 +- .../data/wrapper/impl/media/opera/ParamMap.kt | 4 ++-- .../features/impl/ConfigurationOverride.kt | 2 +- .../rhunk/snapenhance/features/impl/Messaging.kt | 2 +- .../features/impl/downloader/MediaDownloader.kt | 14 +++++++------- .../impl/downloader/ProfilePictureDownloader.kt | 2 +- .../impl/experiments/UnlimitedMultiSnap.kt | 2 +- .../impl/spying/AnonymousStoryViewing.kt | 2 +- .../snapenhance/features/impl/tweaks/AutoSave.kt | 6 +++--- .../features/impl/tweaks/CameraTweaks.kt | 2 +- .../features/impl/tweaks/DisableReplayInFF.kt | 4 ++-- .../features/impl/tweaks/Notifications.kt | 16 ++++++++-------- .../features/impl/tweaks/OldBitmojiSelfie.kt | 2 +- .../features/impl/tweaks/SendOverride.kt | 4 ++-- .../impl/tweaks/UnlimitedSnapViewTime.kt | 8 ++++---- .../features/impl/ui/PinConversations.kt | 4 ++-- .../snapenhance/manager/impl/FeatureManager.kt | 4 ++-- .../ui/menu/impl/FriendFeedInfoMenu.kt | 2 +- 77 files changed, 143 insertions(+), 141 deletions(-) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/Logger.kt (99%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/CallbackBuilder.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/ReflectionHelper.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/SQLiteDatabaseHelper.kt (94%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/SerializableDataObject.kt (93%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/download/HttpServer.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/download/RemoteMediaResolver.kt (95%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/export/MessageExporter.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/ktx/AndroidCompatExtensions.kt (91%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/ktx/DbCursorExt.kt (96%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/ktx/XposedHelperExt.kt (93%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/protobuf/ProtoEditor.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/protobuf/ProtoReader.kt (99%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/protobuf/ProtoWriter.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/protobuf/WireType.kt (84%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/snap/BitmojiSelfie.kt (93%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/snap/EncryptionHelper.kt (88%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/snap/MediaDownloaderHelper.kt (95%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/snap/PreviewUtils.kt (98%) rename core/src/main/kotlin/me/rhunk/snapenhance/{ => core}/util/snap/SnapWidgetBroadcastReceiverHelper.kt (94%) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index 1649e2570..639b32267 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance import android.content.SharedPreferences import android.util.Log import com.google.gson.GsonBuilder +import me.rhunk.snapenhance.core.LogLevel import java.io.File import java.io.OutputStream import java.io.RandomAccessFile diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt index 7c807cf52..cff84850b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.bridge import android.app.Service import android.content.Intent import android.os.IBinder -import me.rhunk.snapenhance.LogLevel +import me.rhunk.snapenhance.core.LogLevel import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.core.bridge.types.BridgeFileType @@ -14,7 +14,7 @@ import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.download.DownloadProcessor -import me.rhunk.snapenhance.util.SerializableDataObject +import me.rhunk.snapenhance.core.util.SerializableDataObject import kotlin.system.measureTimeMillis class BridgeService : Service() { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 21840b304..74bdee9c0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -26,7 +26,7 @@ import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair -import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver import java.io.File import java.io.InputStream import java.net.HttpURLConnection diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt index 916fc83d7..bceac6c41 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -6,9 +6,9 @@ import android.database.sqlite.SQLiteDatabase import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.MediaDownloadSource -import me.rhunk.snapenhance.util.SQLiteDatabaseHelper -import me.rhunk.snapenhance.util.ktx.getIntOrNull -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.core.util.ktx.getIntOrNull +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull class DownloadTaskManager { private lateinit var taskDatabase: SQLiteDatabase diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt index f85ed77e0..691d0f4a3 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -4,9 +4,9 @@ import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.Level import kotlinx.coroutines.suspendCancellableCoroutine -import me.rhunk.snapenhance.LogLevel +import me.rhunk.snapenhance.core.LogLevel import me.rhunk.snapenhance.LogManager -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.config.impl.DownloaderConfig import java.io.File import java.util.concurrent.Executors diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt index 1370e9ebb..4264be91c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -7,10 +7,10 @@ import me.rhunk.snapenhance.core.messaging.FriendStreaks import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.util.SQLiteDatabaseHelper -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getLongOrNull -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getLongOrNull +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull import java.util.concurrent.Executors diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt index fa79b5f2b..56bc6fc13 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -15,7 +15,7 @@ import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.bridge.ForceStartActivity import me.rhunk.snapenhance.ui.util.ImageRequestHelper -import me.rhunk.snapenhance.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie class StreaksReminder( private val remoteSideContext: RemoteSideContext? = null diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt index 9c1448ee9..21a40081b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSubSection.kt @@ -53,8 +53,8 @@ import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.LogChannels -import me.rhunk.snapenhance.LogLevel +import me.rhunk.snapenhance.core.LogChannels +import me.rhunk.snapenhance.core.LogLevel import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.action.EnumAction diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt index 845444f61..b3fc94ea4 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/AddFriendDialog.kt @@ -47,7 +47,7 @@ import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo -import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper class AddFriendDialog( private val context: RemoteSideContext, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt index f07a4701e..577a60151 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -31,7 +31,7 @@ import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.core.messaging.SocialScope import me.rhunk.snapenhance.ui.util.BitmojiImage -import me.rhunk.snapenhance.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie class ScopeContent( private val context: RemoteSideContext, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt index 8f5558a8d..19bd10951 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -57,7 +57,7 @@ import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.AlertDialogs import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset -import me.rhunk.snapenhance.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie class SocialSection : Section() { private lateinit var friendList: List diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt index 7d215f194..5a8cf3926 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -1,14 +1,10 @@ package me.rhunk.snapenhance.ui.setup.screens.impl -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,7 +17,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.util.AlertDialogs diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt index f9336a01f..e0282d8b5 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/ActivityLauncherHelper.kt @@ -4,7 +4,7 @@ import android.content.Intent import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger typealias ActivityLauncherCallback = (resultCode: Int, intent: Intent?) -> Unit diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt index 28b0f4576..1a9188805 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/EventDispatcher.kt @@ -9,15 +9,15 @@ import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.eventbus.events.impl.OnSnapInteractionEvent import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.data.wrapper.impl.MessageContent import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.manager.Manager -import me.rhunk.snapenhance.util.ktx.getObjectField -import me.rhunk.snapenhance.util.ktx.setObjectField -import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper class EventDispatcher( private val context: ModContext diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt index 6ede07e62..736a7c0e2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -11,19 +11,20 @@ import android.widget.Toast import com.google.gson.Gson import com.google.gson.GsonBuilder import kotlinx.coroutines.asCoroutineDispatcher +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.config.ModConfig import me.rhunk.snapenhance.core.database.DatabaseAccess import me.rhunk.snapenhance.core.eventbus.EventBus +import me.rhunk.snapenhance.core.util.download.HttpServer import me.rhunk.snapenhance.data.MessageSender import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.manager.impl.ActionManager import me.rhunk.snapenhance.manager.impl.FeatureManager import me.rhunk.snapenhance.nativelib.NativeConfig import me.rhunk.snapenhance.nativelib.NativeLib -import me.rhunk.snapenhance.util.download.HttpServer import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import kotlin.reflect.KClass diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt index f960cc5b4..8c82f31e1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -8,16 +8,17 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent import me.rhunk.snapenhance.core.eventbus.events.impl.UnaryCallEvent import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo +import me.rhunk.snapenhance.core.util.ktx.getApplicationInfoCompat 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.ktx.getApplicationInfoCompat import kotlin.time.ExperimentalTime import kotlin.time.measureTime diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt index 2ba55c501..d059809ce 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -11,17 +11,17 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.action.AbstractAction +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.export.ExportFormat +import me.rhunk.snapenhance.core.util.export.MessageExporter import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID 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 import java.io.File import kotlin.math.absoluteValue diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/Logger.kt similarity index 99% rename from core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/Logger.kt index 51cd4838f..6470755d4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/Logger.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/Logger.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance +package me.rhunk.snapenhance.core import android.annotation.SuppressLint import android.util.Log diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt index 110d0f943..751a8b098 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/LocaleWrapper.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.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.core.Logger import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.data.LocalePair import java.util.Locale diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt index 02a5d957f..d1a4f269d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MappingsWrapper.kt @@ -5,7 +5,7 @@ import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonParser import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.bridge.FileLoaderWrapper import me.rhunk.snapenhance.core.bridge.types.BridgeFileType import me.rhunk.snapmapper.Mapper diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt index 75d6395c6..c2acc4c53 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/wrapper/MessageLoggerWrapper.kt @@ -2,7 +2,7 @@ package me.rhunk.snapenhance.core.bridge.wrapper import android.content.ContentValues import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper import java.io.File class MessageLoggerWrapper( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index eb095f9e2..2672622f1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -2,8 +2,8 @@ package me.rhunk.snapenhance.core.database import android.annotation.SuppressLint import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.database.objects.ConversationMessage import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry import me.rhunk.snapenhance.core.database.objects.FriendInfo diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt index 6e6c41ff7..2f9476bec 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/ConversationMessage.kt @@ -4,12 +4,12 @@ import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.core.database.DatabaseObject +import me.rhunk.snapenhance.core.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getLong +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.util.ktx.getBlobOrNull -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getLong -import me.rhunk.snapenhance.util.ktx.getStringOrNull -import me.rhunk.snapenhance.util.protobuf.ProtoReader @Suppress("ArrayInDataClass") data class ConversationMessage( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt index 48d43835a..dc28e7d1f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendFeedEntry.kt @@ -3,10 +3,10 @@ package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.core.database.DatabaseObject -import me.rhunk.snapenhance.util.ktx.getIntOrNull -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getLong -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.ktx.getIntOrNull +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getLong +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull data class FriendFeedEntry( var id: Int = 0, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt index 18bd832c2..b36fbddf3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/FriendInfo.kt @@ -3,10 +3,10 @@ package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.core.database.DatabaseObject -import me.rhunk.snapenhance.util.SerializableDataObject -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getLong -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.SerializableDataObject +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getLong +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull data class FriendInfo( var id: Int = 0, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt index 016a2ec35..a6f83d8f4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/StoryEntry.kt @@ -3,8 +3,8 @@ package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.core.database.DatabaseObject -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull data class StoryEntry( var id: Int = 0, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt index 9590c6b13..c036618aa 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/objects/UserConversationLink.kt @@ -3,8 +3,8 @@ package me.rhunk.snapenhance.core.database.objects import android.annotation.SuppressLint import android.database.Cursor import me.rhunk.snapenhance.core.database.DatabaseObject -import me.rhunk.snapenhance.util.ktx.getInteger -import me.rhunk.snapenhance.util.ktx.getStringOrNull +import me.rhunk.snapenhance.core.util.ktx.getInteger +import me.rhunk.snapenhance.core.util.ktx.getStringOrNull class UserConversationLink( var userId: String? = null, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt index b8fa48e11..94a155815 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.messaging -import me.rhunk.snapenhance.util.SerializableDataObject +import me.rhunk.snapenhance.core.util.SerializableDataObject enum class RuleState( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt index b3b82860c..7bd93fce6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util import de.robv.android.xposed.XC_MethodHook import me.rhunk.snapenhance.hook.HookAdapter diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt index 86a026fc5..b2f910ef3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util import java.lang.reflect.Field import java.lang.reflect.Method diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SQLiteDatabaseHelper.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/SQLiteDatabaseHelper.kt index c97680891..0e84ccd03 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SQLiteDatabaseHelper.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util import android.annotation.SuppressLint import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger object SQLiteDatabaseHelper { @SuppressLint("Range") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/SerializableDataObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SerializableDataObject.kt similarity index 93% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/SerializableDataObject.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/SerializableDataObject.kt index 2e4e0bdeb..cf64babd8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/SerializableDataObject.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SerializableDataObject.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util +package me.rhunk.snapenhance.core.util import com.google.gson.Gson import com.google.gson.GsonBuilder diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/HttpServer.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/HttpServer.kt index e50496a22..474797747 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/HttpServer.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/HttpServer.kt @@ -1,11 +1,11 @@ -package me.rhunk.snapenhance.util.download +package me.rhunk.snapenhance.core.util.download import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import java.io.BufferedReader import java.io.InputStream import java.io.InputStreamReader diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt similarity index 95% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt index 839093d07..d7b8bf128 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt @@ -1,7 +1,7 @@ -package me.rhunk.snapenhance.util.download +package me.rhunk.snapenhance.core.util.download import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import okhttp3.OkHttpClient import okhttp3.Request import java.io.ByteArrayInputStream diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/export/MessageExporter.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/export/MessageExporter.kt index e5b148165..15feca36c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/export/MessageExporter.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.export +package me.rhunk.snapenhance.core.util.export import android.os.Environment import android.util.Base64InputStream @@ -11,14 +11,14 @@ import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry import me.rhunk.snapenhance.core.database.objects.FriendInfo +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.util.snap.EncryptionHelper +import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType 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.util.protobuf.ProtoReader -import me.rhunk.snapenhance.util.snap.EncryptionHelper -import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import java.io.File import java.io.FileOutputStream import java.io.InputStream diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/AndroidCompatExtensions.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidCompatExtensions.kt similarity index 91% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/AndroidCompatExtensions.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidCompatExtensions.kt index 7a1b3757d..7a6a4783c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/AndroidCompatExtensions.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidCompatExtensions.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.ktx +package me.rhunk.snapenhance.core.util.ktx import android.content.pm.PackageManager import android.content.pm.PackageManager.ApplicationInfoFlags diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/DbCursorExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/DbCursorExt.kt similarity index 96% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/DbCursorExt.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/DbCursorExt.kt index 3141119fb..e377aae13 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/DbCursorExt.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/DbCursorExt.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.ktx +package me.rhunk.snapenhance.core.util.ktx import android.database.Cursor diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt similarity index 93% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt index 032023788..05f4c5c0d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/ktx/XposedHelperExt.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.ktx +package me.rhunk.snapenhance.core.util.ktx import de.robv.android.xposed.XposedHelpers diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt index 377a769b9..c9359cc79 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoEditor.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.protobuf +package me.rhunk.snapenhance.core.util.protobuf typealias WireCallback = EditorContext.() -> Unit diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt similarity index 99% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt index f0ba5f8fa..a4830ccb7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.protobuf +package me.rhunk.snapenhance.core.util.protobuf data class Wire(val id: Int, val type: WireType, val value: Any) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt index 941abcd02..745221352 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.protobuf +package me.rhunk.snapenhance.core.util.protobuf import java.io.ByteArrayOutputStream diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/WireType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt similarity index 84% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/WireType.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt index a5d55c61a..005d5b868 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/WireType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.protobuf; +package me.rhunk.snapenhance.core.util.protobuf; enum class WireType(val value: Int) { VARINT(0), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/BitmojiSelfie.kt similarity index 93% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/BitmojiSelfie.kt index 71981a488..eda374ff2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/BitmojiSelfie.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.snap +package me.rhunk.snapenhance.core.util.snap object BitmojiSelfie { enum class BitmojiSelfieType( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt similarity index 88% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt index 80f6af5c5..5511231b6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt @@ -1,8 +1,8 @@ -package me.rhunk.snapenhance.util.snap +package me.rhunk.snapenhance.core.util.snap import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.util.protobuf.ProtoReader import java.io.InputStream import java.util.Base64 import javax.crypto.Cipher @@ -12,7 +12,11 @@ import javax.crypto.spec.SecretKeySpec object EncryptionHelper { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair? { - val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(messageProto, contentType, isArroyo) ?: return null + val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo( + messageProto, + contentType, + isArroyo + ) ?: return null val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { Constants.ENCRYPTION_PROTO_INDEX_V2 } else { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/MediaDownloaderHelper.kt similarity index 95% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/MediaDownloaderHelper.kt index d55d5dec5..d65b1fafc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/MediaDownloaderHelper.kt @@ -1,11 +1,11 @@ -package me.rhunk.snapenhance.util.snap +package me.rhunk.snapenhance.core.util.snap import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType +import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.util.protobuf.ProtoReader import java.io.ByteArrayInputStream import java.io.FileNotFoundException import java.io.InputStream diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/PreviewUtils.kt similarity index 98% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/PreviewUtils.kt index acef7a62f..2c8228187 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/PreviewUtils.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.snap +package me.rhunk.snapenhance.core.util.snap import android.graphics.Bitmap import android.graphics.BitmapFactory diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapWidgetBroadcastReceiverHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/SnapWidgetBroadcastReceiverHelper.kt similarity index 94% rename from core/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapWidgetBroadcastReceiverHelper.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/SnapWidgetBroadcastReceiverHelper.kt index 2b825de3b..24fd94614 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapWidgetBroadcastReceiverHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/SnapWidgetBroadcastReceiverHelper.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.util.snap +package me.rhunk.snapenhance.core.util.snap import android.content.Intent import me.rhunk.snapenhance.Constants diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt index ab7fd1530..cc07413ef 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.data -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import java.io.File import java.io.InputStream diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt index f1c522510..f4715f20f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt @@ -1,12 +1,12 @@ package me.rhunk.snapenhance.data import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.protobuf.ProtoWriter class MessageSender( private val context: ModContext, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt index 7ea765749..de432fd36 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.data.wrapper import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.CallbackBuilder import kotlin.reflect.KProperty abstract class AbstractWrapper( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt index 12821618d..9ad755533 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField class Message(obj: Any?) : AbstractWrapper(obj) { val orderKey get() = instanceNonNull().getObjectField("mOrderKey") as Long diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt index 7911bcaa6..69ec52667 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt @@ -1,9 +1,9 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField -import me.rhunk.snapenhance.util.ktx.setObjectField class MessageContent(obj: Any?) : AbstractWrapper(obj) { var content diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt index b168b5d0a..ce1bce99f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt index 44170fa08..bb41d8c0a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDestinations.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField -import me.rhunk.snapenhance.util.ktx.setObjectField @Suppress("UNCHECKED_CAST") class MessageDestinations(obj: Any) : AbstractWrapper(obj){ diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt index 2c767a4b7..bd4e12bee 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.PlayableSnapState import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt index d3c381ca5..95805420d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl import me.rhunk.snapenhance.SnapEnhance +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField import java.nio.ByteBuffer import java.util.UUID diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt index 88d3a79d6..809c299f7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.data.wrapper.impl +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { val userId = SnapUUID(instanceNonNull().getObjectField("mUserId")) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt index 747cb494c..ae21b7735 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl.media import android.os.Parcelable +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ktx.getObjectField import java.lang.reflect.Field diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt index 4ac1fd9e7..efe88e5c5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.data.wrapper.impl.media.opera +import me.rhunk.snapenhance.core.util.ReflectionHelper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper class Layer(obj: Any?) : AbstractWrapper(obj) { val paramMap: ParamMap diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt index 2dccd48d3..5d17be80d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl.media.opera import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.core.util.ReflectionHelper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper import java.lang.reflect.Field import java.util.concurrent.ConcurrentHashMap diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt index 14be6eb9d..f28b7cc91 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.data.wrapper.impl.media.opera +import me.rhunk.snapenhance.core.util.ReflectionHelper +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import me.rhunk.snapenhance.util.ReflectionHelper -import me.rhunk.snapenhance.util.ktx.getObjectField import java.lang.reflect.Field import java.util.concurrent.ConcurrentHashMap diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt index c037872ee..78e6fb9b5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigurationOverride.kt @@ -1,11 +1,11 @@ package me.rhunk.snapenhance.features.impl import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.core.util.ktx.setObjectField 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 me.rhunk.snapenhance.util.ktx.setObjectField class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) { override fun init() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt index 8af58a852..9545bc983 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.features.impl import me.rhunk.snapenhance.core.eventbus.events.impl.OnSnapInteractionEvent +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams @@ -8,7 +9,6 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.util.ktx.getObjectField class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { lateinit var conversationManager: Any diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index 167c757a2..362d53fde 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -16,6 +16,13 @@ import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.download.data.toKeyPair import me.rhunk.snapenhance.core.messaging.MessagingRuleType +import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.util.snap.EncryptionHelper +import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.util.snap.PreviewUtils import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo @@ -31,13 +38,6 @@ 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.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.util.ktx.getObjectField -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.PreviewUtils import java.nio.file.Paths import java.text.SimpleDateFormat import java.util.Locale diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt index 5cd16311a..6e5d6cbcf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/ProfilePictureDownloader.kt @@ -5,12 +5,12 @@ import android.widget.Button import android.widget.RelativeLayout import me.rhunk.snapenhance.core.eventbus.events.impl.AddViewEvent import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader 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 import java.nio.ByteBuffer class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/UnlimitedMultiSnap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/UnlimitedMultiSnap.kt index 94822200a..2c81e11cc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/UnlimitedMultiSnap.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/UnlimitedMultiSnap.kt @@ -1,10 +1,10 @@ package me.rhunk.snapenhance.features.impl.experiments +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hookConstructor -import me.rhunk.snapenhance.util.ktx.setObjectField class UnlimitedMultiSnap : Feature("UnlimitedMultiSnap", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { override fun asyncOnActivityCreate() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt index 086776b17..3891de772 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/AnonymousStoryViewing.kt @@ -2,9 +2,9 @@ package me.rhunk.snapenhance.features.impl.spying import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.util.download.HttpServer import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.util.download.HttpServer import kotlin.coroutines.suspendCoroutine class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt index 370486652..0a8d00a71 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -1,7 +1,9 @@ package me.rhunk.snapenhance.features.impl.tweaks -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.messaging.MessagingRuleType +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID @@ -12,8 +14,6 @@ import me.rhunk.snapenhance.features.impl.spying.MessageLogger import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.ktx.getObjectField import java.util.concurrent.Executors class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt index f6dc24078..564111729 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/CameraTweaks.kt @@ -8,13 +8,13 @@ import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics.Key import android.hardware.camera2.CameraManager import android.util.Range +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.data.wrapper.impl.ScSize 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 me.rhunk.snapenhance.hook.hookConstructor -import me.rhunk.snapenhance.util.ktx.setObjectField class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { companion object { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt index 439211710..a64e51e19 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/DisableReplayInFF.kt @@ -1,11 +1,11 @@ package me.rhunk.snapenhance.features.impl.tweaks +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setEnumField import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hookConstructor -import me.rhunk.snapenhance.util.ktx.getObjectField -import me.rhunk.snapenhance.util.ktx.setEnumField class DisableReplayInFF : Feature("DisableReplayInFF", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { override fun asyncOnActivityCreate() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt index 2f4149288..cd5d09acf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -12,9 +12,16 @@ import android.os.Bundle import android.os.UserHandle import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.util.snap.EncryptionHelper +import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.core.util.snap.PreviewUtils +import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MediaReferenceType import me.rhunk.snapenhance.data.wrapper.impl.Message @@ -26,13 +33,6 @@ 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.ktx.setObjectField -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.PreviewUtils -import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { companion object{ diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt index b860c5754..d08f37180 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt @@ -1,9 +1,9 @@ package me.rhunk.snapenhance.features.impl.tweaks import me.rhunk.snapenhance.core.eventbus.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.util.snap.BitmojiSelfie class OldBitmojiSelfie : Feature("OldBitmojiSelfie", loadParams = FeatureLoadParams.INIT_SYNC) { override fun init() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt index a7e0672ff..2ebdb34cd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt @@ -3,13 +3,13 @@ package me.rhunk.snapenhance.features.impl.tweaks import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.core.eventbus.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MessageSender import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.util.protobuf.ProtoReader class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { private var isLastSnapSavable = false diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt index be32bc7ca..94d988ca7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/UnlimitedSnapViewTime.kt @@ -1,20 +1,20 @@ package me.rhunk.snapenhance.features.impl.tweaks +import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.wrapper.impl.Message 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.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.util.protobuf.ProtoReader +import me.rhunk.snapenhance.hook.hookConstructor class UnlimitedSnapViewTime : Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { override fun onActivityCreate() { val state by context.config.messaging.unlimitedSnapViewTime - Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, { state }) { param -> + context.classCache.message.hookConstructor(HookStage.AFTER, { state }) { param -> val message = Message(param.thisObject()) if (message.messageState != MessageState.COMMITTED) return@hookConstructor if (message.messageContent.contentType != ContentType.SNAP) return@hookConstructor diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt index 5b6b57c8e..2422a728e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt @@ -1,14 +1,14 @@ package me.rhunk.snapenhance.features.impl.ui import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hookConstructor -import me.rhunk.snapenhance.util.ktx.getObjectField -import me.rhunk.snapenhance.util.ktx.setObjectField class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PINNED_CONVERSATIONS, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { override fun onActivityCreate() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt index dd2ec57cf..ed69fb892 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.manager.impl -import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.impl.ConfigurationOverride @@ -34,8 +34,8 @@ import me.rhunk.snapenhance.features.impl.tweaks.OldBitmojiSelfie import me.rhunk.snapenhance.features.impl.tweaks.SendOverride import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime -import me.rhunk.snapenhance.features.impl.ui.PinConversations import me.rhunk.snapenhance.features.impl.ui.ClientBootstrapOverride +import me.rhunk.snapenhance.features.impl.ui.PinConversations import me.rhunk.snapenhance.features.impl.ui.UITweaks import me.rhunk.snapenhance.manager.Manager import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt index 918d89d41..21a069f8b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -14,6 +14,7 @@ import android.widget.Switch import me.rhunk.snapenhance.core.database.objects.ConversationMessage import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.database.objects.UserConversationLink +import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.features.impl.Messaging @@ -22,7 +23,6 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.tweaks.AutoSave import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.AbstractMenu -import me.rhunk.snapenhance.util.snap.BitmojiSelfie import java.net.HttpURLConnection import java.net.URL import java.text.DateFormat From f329a026972dfa73e740bcadfd43d25336126d1f Mon Sep 17 00:00:00 2001 From: ReSo7200 <47777771+ReSo7200@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:46:17 +0300 Subject: [PATCH 046/274] add(readme): faq Co-authored-by: auth <64337177+authorisation@users.noreply.github.com> --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ecf951403..6be13e381 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,62 @@ We no longer offer official LSPatch binaries for obvious reasons. However, you'r - Chat Export (HTML, JSON and TXT) -## Privacy +## FAQ +
+ AI wallpapers and the Snapchat+ badge aren't working! + + - Yeah, they're server-sided and will probably never work. +
+ +
+ Can you add this feature, please? + + - Open an issue on our Github repo. +
+
+ When will this feature become available or finish? + + - At some point. +
+ +
+ Can I get banned with this? + + - Obviously, however, the risk is very low, and we have no reported cases of anyone ever getting banned while using the mod. +
+ +
+ Can I PM the developers? + + - No. +
+ +
+ This doesn't work! + + - Open an issue. +
+ +
+ My phone isn't rooted; how do I use this? + + - You can use LSPatch in combination with SnapEnhance to run this on an unrooted device, however this is unrecommended and not considered safe. +
+ +
+ Where can I download the latest stable build? + + - https://github.com/rhunk/snapenhance/releases +
+ +
+ Can I use HideMyApplist with this? + + - No, this will cause some severe issues, and the mod will not be able to inject. +
+ +## Privacy We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies.
Permissions From 684cb2611643818b146af359cea5bafa783ffdb4 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 13 Sep 2023 15:53:50 +0200 Subject: [PATCH 047/274] build: remove DSL_SCOPE_VIOLATION - remove unused comments --- app/build.gradle.kts | 1 - build.gradle.kts | 1 - core/build.gradle.kts | 2 +- gradle.properties | 21 +-------------------- mapper/build.gradle.kts | 3 +-- native/build.gradle.kts | 1 - 6 files changed, 3 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6835cbce5..dad5ffa90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ 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) diff --git a/build.gradle.kts b/build.gradle.kts index e3e9aa755..c9cff6601 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,4 @@ // 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.androidLibrary) apply false diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c071dc9d1..bf510b8c0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,8 +1,8 @@ -@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) } + android { namespace = rootProject.ext["applicationId"].toString() + ".core" compileSdk = 34 diff --git a/gradle.properties b/gradle.properties index 2a7ec6959..224e02f4b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,24 +1,5 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library android.nonTransitiveRClass=true android.nonFinalResIds=false \ No newline at end of file diff --git a/mapper/build.gradle.kts b/mapper/build.gradle.kts index eaa8efe1a..5ff1f8d74 100644 --- a/mapper/build.gradle.kts +++ b/mapper/build.gradle.kts @@ -1,6 +1,5 @@ -@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { - id("com.android.library") + alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) } diff --git a/native/build.gradle.kts b/native/build.gradle.kts index 5d1bf408f..64752247e 100644 --- a/native/build.gradle.kts +++ b/native/build.gradle.kts @@ -1,4 +1,3 @@ -@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) From 554ead7e9af48dfb37cc96af165fa6f276dfccec Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:19:12 +0200 Subject: [PATCH 048/274] feat: empty scripting engine submodule --- scripting/.gitignore | 1 + scripting/build.gradle.kts | 17 +++++++++++++++++ settings.gradle.kts | 1 + 3 files changed, 19 insertions(+) create mode 100644 scripting/.gitignore create mode 100644 scripting/build.gradle.kts diff --git a/scripting/.gitignore b/scripting/.gitignore new file mode 100644 index 000000000..d16386367 --- /dev/null +++ b/scripting/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/scripting/build.gradle.kts b/scripting/build.gradle.kts new file mode 100644 index 000000000..08e0579f7 --- /dev/null +++ b/scripting/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".scripting" + compileSdk = 34 + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(project(":core")) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 132f3f5d8..c35505a4a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,4 @@ include(":core") include(":app") include(":mapper") include(":native") +include(":scripting") From bf73babf0d7101b474a8776ffd34ad4708f82ee5 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 13 Sep 2023 19:25:27 +0200 Subject: [PATCH 049/274] build(native): ccache --- native/jni/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/native/jni/CMakeLists.txt b/native/jni/CMakeLists.txt index 0b5be9206..d7e024388 100644 --- a/native/jni/CMakeLists.txt +++ b/native/jni/CMakeLists.txt @@ -1,5 +1,11 @@ cmake_minimum_required(VERSION 3.22.1) +find_program(CCACHE_FOUND ccache) +if(CCACHE_FOUND) + set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ccache) + set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK ccache) +endif(CCACHE_FOUND) + project("nativelib") set(DOBBY_GENERATE_SHARED OFF) From 2e4b161eebeb229c732208da2f8faebc1b72f63f Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 13 Sep 2023 19:26:59 +0200 Subject: [PATCH 050/274] feat(media_downloader): better preview of chat messages - set media resolver url to web api --- .../core/util/download/RemoteMediaResolver.kt | 2 +- .../me/rhunk/snapenhance/features/Feature.kt | 4 + .../impl/downloader/MediaDownloader.kt | 95 ++++++++++++++----- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt index d7b8bf128..8cf72a9e6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/RemoteMediaResolver.kt @@ -9,7 +9,7 @@ import java.io.InputStream import java.util.Base64 object RemoteMediaResolver { - private const val BOLT_HTTP_RESOLVER_URL = "https://aws.api.snapchat.com/bolt-http" + private const val BOLT_HTTP_RESOLVER_URL = "https://web.snapchat.com/bolt-http" const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/" private val urlCache = mutableMapOf() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt index 49e1a6090..42ae19dde 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt @@ -32,4 +32,8 @@ abstract class Feature( protected fun findClass(name: String): Class<*> { return context.androidContext.classLoader.loadClass(name) } + + protected fun runOnUiThread(block: () -> Unit) { + context.runOnUiThread(block) + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index 362d53fde..f99423950 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -1,10 +1,18 @@ package me.rhunk.snapenhance.features.impl.downloader -import android.content.DialogInterface +import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.view.Gravity +import android.view.ViewGroup.MarginLayoutParams +import android.view.Window import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.core.database.objects.FriendInfo @@ -475,6 +483,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } + @SuppressLint("SetTextI18n") + @OptIn(ExperimentalCoroutinesApi::class) fun downloadMessageId(messageId: Long, isPreview: Boolean = false) { val messageLogger = context.feature(MessageLogger::class) val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") @@ -557,35 +567,76 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return } - val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) { - EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage) - } + runBlocking { + val previewCoroutine = async { + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) { + EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage) + } - runCatching { - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - if (bitmap == null) { - context.shortToast(translations["failed_to_create_preview_toast"]) - return - } + if (bitmap == null) { + context.shortToast(translations["failed_to_create_preview_toast"]) + return@async null + } - overlay?.let { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + bitmap } with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { - setView(ImageView(context).apply { - setImageBitmap(bitmap) - }) - setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() } - this@MediaDownloader.context.runOnUiThread { show()} + val viewGroup = LinearLayout(context).apply { + layoutParams = MarginLayoutParams(MarginLayoutParams.MATCH_PARENT, MarginLayoutParams.MATCH_PARENT) + gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL + addView(ProgressBar(context).apply { + isIndeterminate = true + }) + } + + setOnDismissListener { + previewCoroutine.cancel() + } + + previewCoroutine.invokeOnCompletion { cause -> + runOnUiThread { + viewGroup.removeAllViews() + if (cause != null) { + viewGroup.addView(TextView(context).apply { + text = translations["failed_to_create_preview_toast"] + "\n" + cause.message + setPadding(30, 30, 30, 30) + }) + return@runOnUiThread + } + + viewGroup.addView(ImageView(context).apply { + setImageBitmap(previewCoroutine.getCompleted()) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + adjustViewBounds = true + }) + } + } + + runOnUiThread { + show().apply { + setContentView(viewGroup) + requestWindowFeature(Window.FEATURE_NO_TITLE) + window?.setLayout( + context.resources.displayMetrics.widthPixels, + context.resources.displayMetrics.heightPixels + ) + } + previewCoroutine.start() + } } - }.onFailure { - context.shortToast(translations["failed_to_create_preview_toast"]) - context.log.error("Failed to create preview", it) } }.onFailure { context.longToast(translations["failed_generic_toast"]) From 5767f9333132cf9b91007546a8ff88334bf481aa Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 14 Sep 2023 23:49:46 +0200 Subject: [PATCH 051/274] fix(pin_conversations): set to rule feature --- .../core/messaging/MessagingCoreObjects.kt | 3 ++- .../snapenhance/features/MessagingRuleFeature.kt | 5 +---- .../features/impl/ui/PinConversations.kt | 14 ++++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt index 94a155815..b0a544f24 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessagingCoreObjects.kt @@ -29,7 +29,8 @@ enum class MessagingRuleType( AUTO_DOWNLOAD("auto_download", true), STEALTH("stealth", true), AUTO_SAVE("auto_save", true), - HIDE_CHAT_FEED("hide_chat_feed", false); + HIDE_CHAT_FEED("hide_chat_feed", false), + PIN_CONVERSATION("pin_conversation", false); fun translateOptionKey(optionKey: String): String { return "rules.properties.${key}.options.${optionKey}" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt index 85a90296e..86d66c5db 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt @@ -4,11 +4,8 @@ import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.core.messaging.RuleState abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType, loadParams: Int = 0) : Feature(name, loadParams) { - init { - if (!ruleType.listMode) throw IllegalArgumentException("Rule type must be a list mode") - } - fun getRuleState() = context.config.rules.getRuleState(ruleType) + open fun getRuleState() = context.config.rules.getRuleState(ruleType) fun setState(conversationId: String, state: Boolean) { context.bridgeClient.setRule( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt index 2422a728e..32e298ea7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/PinConversations.kt @@ -1,28 +1,28 @@ package me.rhunk.snapenhance.features.impl.ui -import me.rhunk.snapenhance.core.bridge.types.BridgeFileType +import me.rhunk.snapenhance.core.messaging.MessagingRuleType +import me.rhunk.snapenhance.core.messaging.RuleState import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.BridgeFileFeature import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hookConstructor -class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PINNED_CONVERSATIONS, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { +class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { override fun onActivityCreate() { context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param -> val conversationUUID = SnapUUID(param.arg(0)) val isPinned = param.arg(1).toString() == "PINNED" - setState(conversationUUID.toString(), isPinned) } context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) - if (exists(conversationUUID.toString())) { + if (getState(conversationUUID.toString())) { instance.setObjectField("mPinnedTimestampMs", 1L) } } @@ -30,10 +30,12 @@ class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PI context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) - val isPinned = exists(conversationUUID.toString()) + val isPinned = getState(conversationUUID.toString()) if (isPinned) { instance.setObjectField("mPinnedTimestampMs", 1L) } } } + + override fun getRuleState() = RuleState.WHITELIST } \ No newline at end of file From 904d9175b69b20156d6f083a4d389149655ab908 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 14 Sep 2023 23:55:06 +0200 Subject: [PATCH 052/274] feat(download_manager): download multiple attachment - experimental message decoder - fix async calls for downloads database - add debug view for ProtoReader --- .../snapenhance/download/DownloadProcessor.kt | 4 +- .../download/DownloadTaskManager.kt | 76 +++--- core/src/main/assets/lang/en_US.json | 12 + .../core/download/DownloadManagerClient.kt | 6 +- .../core/download/data/DownloadRequest.kt | 2 +- .../download/data/MediaEncryptionKeyPair.kt | 2 +- .../core/util/protobuf/ProtoEditor.kt | 4 +- .../core/util/protobuf/ProtoReader.kt | 84 +++++- .../core/util/protobuf/ProtoWriter.kt | 4 +- .../core/util/protobuf/WireType.kt | 2 +- .../core/util/snap/EncryptionHelper.kt | 28 +- .../impl/downloader/MediaDownloader.kt | 247 ++++++++++-------- .../impl/downloader/decoder/AttachmentInfo.kt | 15 ++ .../impl/downloader/decoder/AttachmentType.kt | 11 + .../impl/downloader/decoder/MessageDecoder.kt | 144 ++++++++++ 15 files changed, 480 insertions(+), 161 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentInfo.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentType.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/MessageDecoder.kt diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 74bdee9c0..698319e75 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -8,8 +8,6 @@ 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 @@ -254,7 +252,7 @@ class DownloadProcessor ( val media = downloadedMedias[inputMedia]!! if (!downloadRequest.isDashPlaylist) { - if (inputMedia.messageContentType == "NOTE") { + if (inputMedia.attachmentType == "NOTE") { remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> val outputFile = File.createTempFile("voice_note", ".$format") ffmpegProcessor.execute(FFMpegProcessor.Request( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt index bceac6c41..032bd3917 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -9,11 +9,13 @@ import me.rhunk.snapenhance.core.download.data.MediaDownloadSource import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.core.util.ktx.getIntOrNull import me.rhunk.snapenhance.core.util.ktx.getStringOrNull +import java.util.concurrent.Executors class DownloadTaskManager { private lateinit var taskDatabase: SQLiteDatabase private val pendingTasks = mutableMapOf() private val cachedTasks = mutableMapOf() + private val executor = Executors.newSingleThreadExecutor() @SuppressLint("Range") fun init(context: Context) { @@ -34,39 +36,43 @@ class DownloadTaskManager { } } - fun addTask(task: DownloadObject): Int { - taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, downloadSource, mediaAuthor, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.downloadSource, - task.metadata.mediaAuthor, - task.metadata.iconUrl, - task.downloadStage.name + fun addTask(task: DownloadObject) { + executor.execute { + taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, downloadSource, mediaAuthor, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", + arrayOf( + task.metadata.mediaIdentifier, + task.metadata.outputPath, + task.outputFile, + task.metadata.downloadSource, + task.metadata.mediaAuthor, + task.metadata.iconUrl, + task.downloadStage.name + ) ) - ) - task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { - it.moveToFirst() - it.getInt(0) + task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { + it.moveToFirst() + it.getInt(0) + } + pendingTasks[task.downloadId] = task } - pendingTasks[task.downloadId] = task - return task.downloadId } fun updateTask(task: DownloadObject) { - taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, downloadSource = ?, mediaAuthor = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", - arrayOf( - task.metadata.mediaIdentifier, - task.metadata.outputPath, - task.outputFile, - task.metadata.downloadSource, - task.metadata.mediaAuthor, - task.metadata.iconUrl, - task.downloadStage.name, - task.downloadId + executor.execute { + taskDatabase.execSQL( + "UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, downloadSource = ?, mediaAuthor = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", + arrayOf( + task.metadata.mediaIdentifier, + task.metadata.outputPath, + task.outputFile, + task.metadata.downloadSource, + task.metadata.mediaAuthor, + task.metadata.iconUrl, + task.downloadStage.name, + task.downloadId + ) ) - ) + } //if the task is no longer active, move it to the cached tasks if (task.isJobActive()) { pendingTasks[task.downloadId] = task @@ -103,9 +109,11 @@ class DownloadTaskManager { } private fun removeTask(id: Int) { - taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(id)) - cachedTasks.remove(id) - pendingTasks.remove(id) + executor.execute { + taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(id)) + cachedTasks.remove(id) + pendingTasks.remove(id) + } } fun removeTask(task: DownloadObject) { @@ -174,8 +182,10 @@ class DownloadTaskManager { } fun removeAllTasks() { - taskDatabase.execSQL("DELETE FROM tasks") - cachedTasks.clear() - pendingTasks.clear() + executor.execute { + taskDatabase.execSQL("DELETE FROM tasks") + cachedTasks.clear() + pendingTasks.clear() + } } } \ No newline at end of file diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json index 800fcca42..208c0bf2d 100644 --- a/core/src/main/assets/lang/en_US.json +++ b/core/src/main/assets/lang/en_US.json @@ -97,6 +97,9 @@ }, "hide_chat_feed": { "name": "Hide from Chat feed" + }, + "pin_conversation": { + "name": "Pin Conversation" } } }, @@ -696,9 +699,18 @@ }, "download_processor": { + "attachment_type": { + "snap": "Snap", + "sticker": "Sticker", + "external_media": "External Media", + "note": "Note", + "original_story": "Original Story" + }, + "select_attachments_title": "Select attachments to download", "download_started_toast": "Download started", "unsupported_content_type_toast": "Unsupported content type!", "failed_no_longer_available_toast": "Media no longer available", + "no_attachments_toast": "No attachments found!", "already_queued_toast": "Media already in queue!", "already_downloaded_toast": "Media already downloaded!", "saved_toast": "Saved to {path}", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt index 69642a007..45ab67624 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/DownloadManagerClient.kt @@ -10,7 +10,7 @@ import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair -import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.features.impl.downloader.decoder.AttachmentType class DownloadManagerClient ( private val context: ModContext, @@ -50,7 +50,7 @@ class DownloadManagerClient ( mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null, - messageContentType: ContentType? = null + attachmentType: AttachmentType? = null ) { enqueueDownloadRequest( DownloadRequest( @@ -59,7 +59,7 @@ class DownloadManagerClient ( content = mediaData, type = mediaType, encryption = encryption, - messageContentType = messageContentType?.name + attachmentType = attachmentType?.name ) ) ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt index 5105e77b4..fc0facad6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -6,7 +6,7 @@ data class InputMedia( val content: String, val type: DownloadMediaType, val encryption: MediaEncryptionKeyPair? = null, - val messageContentType: String? = null, + val attachmentType: String? = null, val isOverlay: Boolean = false, ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt index 30a13c4f8..66b422ccc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/MediaEncryptionKeyPair.kt @@ -6,7 +6,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.EncryptionWrapper import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -// key and iv are base64 encoded +// key and iv are base64 encoded into url safe strings data class MediaEncryptionKeyPair( val key: String, val iv: String diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt index c9359cc79..7d8d0b16b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoEditor.kt @@ -14,7 +14,7 @@ class EditorContext( } fun addVarInt(id: Int, value: Int) = addVarInt(id, value.toLong()) fun addVarInt(id: Int, value: Long) = addWire(Wire(id, WireType.VARINT, value)) - fun addBuffer(id: Int, value: ByteArray) = addWire(Wire(id, WireType.LENGTH_DELIMITED, value)) + fun addBuffer(id: Int, value: ByteArray) = addWire(Wire(id, WireType.CHUNK, value)) fun add(id: Int, content: ProtoWriter.() -> Unit) = addBuffer(id, ProtoWriter().apply(content).toByteArray()) fun addString(id: Int, value: String) = addBuffer(id, value.toByteArray()) fun addFixed64(id: Int, value: Long) = addWire(Wire(id, WireType.FIXED64, value)) @@ -48,7 +48,7 @@ class ProtoEditor( wires.getOrPut(wireId) { mutableListOf() }.add(value) return@forEach } - wires[wireId]!!.add(Wire(wireId, WireType.LENGTH_DELIMITED, writeAtPath(path, currentIndex + 1, childReader, wireToWriteCallback))) + wires[wireId]!!.add(Wire(wireId, WireType.CHUNK, writeAtPath(path, currentIndex + 1, childReader, wireToWriteCallback))) return@forEach } wires[wireId]!!.add(value) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt index a4830ccb7..0db7cb003 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoReader.kt @@ -1,5 +1,7 @@ package me.rhunk.snapenhance.core.util.protobuf +import java.util.UUID + data class Wire(val id: Int, val type: WireType, val value: Any) class ProtoReader(private val buffer: ByteArray) { @@ -43,7 +45,7 @@ class ProtoReader(private val buffer: ByteArray) { } bytes } - WireType.LENGTH_DELIMITED -> { + WireType.CHUNK -> { val length = readVarInt().toInt() val bytes = ByteArray(length) for (i in 0 until length) { @@ -135,7 +137,7 @@ class ProtoReader(private val buffer: ByteArray) { fun eachBuffer(reader: (Int, ByteArray) -> Unit) { values.forEach { (id, wires) -> wires.forEach { wire -> - if (wire.type == WireType.LENGTH_DELIMITED) { + if (wire.type == WireType.CHUNK) { reader(id, wire.value as ByteArray) } } @@ -172,4 +174,82 @@ class ProtoReader(private val buffer: ByteArray) { } return value } + + private fun prettyPrint(tabSize: Int): String { + val tabLine = " ".repeat(tabSize) + val stringBuilder = StringBuilder() + values.forEach { (id, wires) -> + wires.forEach { wire -> + stringBuilder.append(tabLine) + stringBuilder.append("$id <${wire.type.name.lowercase()}> = ") + when (wire.type) { + WireType.VARINT -> stringBuilder.append("${wire.value}\n") + WireType.FIXED64, WireType.FIXED32 -> { + //print as double, int, floating point + val doubleValue = run { + val bytes = wire.value as ByteArray + var value = 0L + for (i in bytes.indices) { + value = value or ((bytes[i].toLong() and 0xFF) shl (i * 8)) + } + value + }.let { + if (wire.type == WireType.FIXED32) { + it.toInt() + } else { + it + } + } + + stringBuilder.append("$doubleValue/${doubleValue.toDouble().toBits().toString(16)}\n") + } + WireType.CHUNK -> { + fun printArray() { + stringBuilder.append("\n") + stringBuilder.append("$tabLine ") + stringBuilder.append((wire.value as ByteArray).joinToString(" ") { byte -> "%02x".format(byte) }) + stringBuilder.append("\n") + } + runCatching { + val array = (wire.value as ByteArray) + if (array.isEmpty()) { + stringBuilder.append("empty\n") + return@runCatching + } + //auto detect ascii strings + if (array.all { it in 0x20..0x7E }) { + stringBuilder.append("string: ${array.toString(Charsets.UTF_8)}\n") + return@runCatching + } + + // auto detect uuids + if (array.size == 16) { + val longs = LongArray(2) + for (i in 0..7) { + longs[0] = longs[0] or ((array[i].toLong() and 0xFF) shl (i * 8)) + } + for (i in 8..15) { + longs[1] = longs[1] or ((array[i].toLong() and 0xFF) shl ((i - 8) * 8)) + } + stringBuilder.append("uuid: ${UUID(longs[0], longs[1])}\n") + return@runCatching + } + + ProtoReader(array).prettyPrint(tabSize + 1).takeIf { it.isNotEmpty() }?.let { + stringBuilder.append("message:\n") + stringBuilder.append(it) + } ?: printArray() + }.onFailure { + printArray() + } + } + else -> stringBuilder.append("unknown\n") + } + } + } + + return stringBuilder.toString() + } + + override fun toString() = prettyPrint(0) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt index 745221352..06f835ee4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/ProtoWriter.kt @@ -24,7 +24,7 @@ class ProtoWriter { } fun addBuffer(id: Int, value: ByteArray) { - writeVarInt(id shl 3 or WireType.LENGTH_DELIMITED.value) + writeVarInt(id shl 3 or WireType.CHUNK.value) writeVarInt(value.size) stream.write(value) } @@ -98,7 +98,7 @@ class ProtoWriter { is ByteArray -> stream.write(wire.value) } } - WireType.LENGTH_DELIMITED -> { + WireType.CHUNK -> { val value = wire.value as ByteArray writeVarInt(value.size) stream.write(value) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt index 005d5b868..f9dcea29f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/protobuf/WireType.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.util.protobuf; enum class WireType(val value: Int) { VARINT(0), FIXED64(1), - LENGTH_DELIMITED(2), + CHUNK(2), START_GROUP(3), END_GROUP(4), FIXED32(5); diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt index 5511231b6..62858ef25 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt @@ -1,15 +1,18 @@ package me.rhunk.snapenhance.core.util.snap import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair import me.rhunk.snapenhance.core.util.protobuf.ProtoReader import me.rhunk.snapenhance.data.ContentType import java.io.InputStream -import java.util.Base64 import javax.crypto.Cipher import javax.crypto.CipherInputStream import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +@OptIn(ExperimentalEncodingApi::class) object EncryptionHelper { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair? { val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo( @@ -28,9 +31,8 @@ object EncryptionHelper { var iv: ByteArray = encryptionProto.getByteArray(2)!! if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) { - val decoder = Base64.getMimeDecoder() - key = decoder.decode(key) - iv = decoder.decode(iv) + key = Base64.UrlSafe.decode(key) + iv = Base64.UrlSafe.decode(iv) } return Pair(key, iv) @@ -50,4 +52,22 @@ object EncryptionHelper { return CipherInputStream(inputStream, cipher) } } + + fun decryptInputStream( + inputStream: InputStream, + mediaEncryptionKeyPair: MediaEncryptionKeyPair? + ): InputStream { + if (mediaEncryptionKeyPair == null) { + return inputStream + } + + Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.DECRYPT_MODE, + SecretKeySpec(Base64.UrlSafe.decode(mediaEncryptionKeyPair.key), "AES"), + IvParameterSpec(Base64.UrlSafe.decode(mediaEncryptionKeyPair.iv)) + ) + }.let { cipher -> + return CipherInputStream(inputStream, cipher) + } + } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt index f99423950..fbb7f7eae 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -6,7 +6,6 @@ import android.graphics.BitmapFactory import android.net.Uri import android.view.Gravity import android.view.ViewGroup.MarginLayoutParams -import android.view.Window import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar @@ -15,6 +14,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.core.database.objects.ConversationMessage import me.rhunk.snapenhance.core.database.objects.FriendInfo import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.core.download.data.DownloadMediaType @@ -31,7 +31,6 @@ import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie import me.rhunk.snapenhance.core.util.snap.EncryptionHelper import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.core.util.snap.PreviewUtils -import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem @@ -41,6 +40,8 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.downloader.decoder.DecodedAttachment +import me.rhunk.snapenhance.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.features.impl.spying.MessageLogger import me.rhunk.snapenhance.hook.HookAdapter import me.rhunk.snapenhance.hook.HookStage @@ -69,6 +70,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private var lastSeenMediaInfoMap: MutableMap? = null private var lastSeenMapParams: ParamMap? = null + private val translations by lazy { + context.translation.getCategory("download_processor") + } + private fun provideDownloadManagerClient( mediaIdentifier: String, mediaAuthor: String, @@ -81,7 +86,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val downloadLogging by context.config.downloader.logging if (downloadLogging.contains("started")) { - context.shortToast(context.translation["download_processor.download_started_toast"]) + context.shortToast(translations["download_started_toast"]) } val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) @@ -101,7 +106,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp override fun onSuccess(outputFile: String) { if (!downloadLogging.contains("success")) return context.log.verbose("onSuccess: outputFile=$outputFile") - context.shortToast(context.translation.format("download_processor.saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) + context.shortToast(translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) } override fun onProgress(message: String) { @@ -483,6 +488,34 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } + private fun downloadMessageAttachments( + friendInfo: FriendInfo, + message: ConversationMessage, + authorName: String, + attachments: List + ) { + //TODO: stickers + attachments.forEach { attachment -> + runCatching { + provideDownloadManagerClient( + mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.attachmentInfo?.encryption?.iv}", + downloadSource = MediaDownloadSource.CHAT_MEDIA, + mediaAuthor = authorName, + friendInfo = friendInfo + ).downloadSingleMedia( + mediaData = attachment.mediaKey!!, + mediaType = DownloadMediaType.PROTO_MEDIA, + encryption = attachment.attachmentInfo?.encryption, + attachmentType = attachment.type + ) + }.onFailure { + context.longToast(translations["failed_generic_toast"]) + context.log.error("Failed to download", it) + } + } + } + + @SuppressLint("SetTextI18n") @OptIn(ExperimentalCoroutinesApi::class) fun downloadMessageId(messageId: Long, isPreview: Boolean = false) { @@ -494,15 +527,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val authorName = friendInfo.usernameForSorting!! var messageContent = message.messageContent!! - var isArroyoMessage = true - var deletedMediaReference: ByteArray? = null - - //check if the messageId - var contentType: ContentType = ContentType.fromId(message.contentType) + var customMediaReferences = mutableListOf() if (messageLogger.isMessageRemoved(message.clientConversationId!!, message.serverMessageId.toLong())) { val messageObject = messageLogger.getMessageObject(message.clientConversationId!!, message.serverMessageId.toLong()) ?: throw Exception("Message not found in database") - isArroyoMessage = false val messageContentObject = messageObject.getAsJsonObject("mMessageContent") messageContent = messageContentObject @@ -510,137 +538,138 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp .map { it.asByte } .toByteArray() - contentType = ContentType.valueOf(messageContentObject - .getAsJsonPrimitive("mContentType").asString - ) - - deletedMediaReference = messageContentObject.getAsJsonArray("mRemoteMediaReferences") + customMediaReferences = messageContentObject + .getAsJsonArray("mRemoteMediaReferences") .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } - .flatten().let { reference -> - if (reference.isEmpty()) return@let null - reference[0].asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + .flatten().map { reference -> + Base64.UrlSafe.encode( + reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + ) } - } - - val translations = context.translation.getCategory("download_processor") - - if (contentType != ContentType.NOTE && - contentType != ContentType.SNAP && - contentType != ContentType.EXTERNAL_MEDIA) { - context.shortToast(translations["unsupported_content_type_toast"]) - return + .toMutableList() } val messageReader = ProtoReader(messageContent) - val urlProto: ByteArray? = if (isArroyoMessage) { - var finalProto: ByteArray? = null - messageReader.eachBuffer(4, 5) { - finalProto = getByteArray(1, 3) - } - finalProto - } else deletedMediaReference + val decodedAttachments = MessageDecoder.decode( + protoReader = messageReader, + customMediaReferences = customMediaReferences.takeIf { it.isNotEmpty() } + ) - if (urlProto == null) { - context.shortToast(translations["unsupported_content_type_toast"]) + if (decodedAttachments.isEmpty()) { + context.shortToast(translations["no_attachments_toast"]) return } - runCatching { - if (!isPreview) { - val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) - provideDownloadManagerClient( - mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}", - downloadSource = MediaDownloadSource.CHAT_MEDIA, - mediaAuthor = authorName, - friendInfo = friendInfo - ).downloadSingleMedia( - mediaData = Base64.UrlSafe.encode(urlProto), - mediaType = DownloadMediaType.PROTO_MEDIA, - encryption = encryptionKeys?.toKeyPair(), - messageContentType = contentType + if (!isPreview) { + if (decodedAttachments.size == 1) { + downloadMessageAttachments(friendInfo, message, authorName, + listOf(decodedAttachments.first()) ) return } - if (EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) == null) { - context.shortToast(translations["failed_no_longer_available_toast"]) - return + runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + val selectedAttachments = mutableListOf().apply { + addAll(decodedAttachments.indices) + } + setMultiChoiceItems( + decodedAttachments.mapIndexed { index, decodedAttachment -> + "${index + 1}: ${translations["attachment_type.${decodedAttachment.type.key}"]} ${decodedAttachment.attachmentInfo?.resolution?.let { "(${it.first}x${it.second})" } ?: ""}" + }.toTypedArray(), + decodedAttachments.map { true }.toBooleanArray() + ) { _, which, isChecked -> + if (isChecked) { + selectedAttachments.add(which) + } else if (selectedAttachments.contains(which)) { + selectedAttachments.remove(which) + } + } + setTitle(translations["select_attachments_title"]) + setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } + setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> + downloadMessageAttachments(friendInfo, message, authorName, selectedAttachments.map { decodedAttachments[it] }) + } + }.show() } - runBlocking { - val previewCoroutine = async { - val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) { - EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage) - } + return + } - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + runBlocking { + val firstAttachment = decodedAttachments.first() - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + val previewCoroutine = async { + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(Base64.decode(firstAttachment.mediaKey!!)) { + EncryptionHelper.decryptInputStream( + it, + decodedAttachments.first().attachmentInfo?.encryption + ) + } - if (bitmap == null) { - context.shortToast(translations["failed_to_create_preview_toast"]) - return@async null - } + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) - } + var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - bitmap + if (bitmap == null) { + context.shortToast(translations["failed_to_create_preview_toast"]) + return@async null } - with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { - val viewGroup = LinearLayout(context).apply { - layoutParams = MarginLayoutParams(MarginLayoutParams.MATCH_PARENT, MarginLayoutParams.MATCH_PARENT) - gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL - addView(ProgressBar(context).apply { - isIndeterminate = true - }) - } + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + } - setOnDismissListener { - previewCoroutine.cancel() - } + bitmap + } - previewCoroutine.invokeOnCompletion { cause -> - runOnUiThread { - viewGroup.removeAllViews() - if (cause != null) { - viewGroup.addView(TextView(context).apply { - text = translations["failed_to_create_preview_toast"] + "\n" + cause.message - setPadding(30, 30, 30, 30) - }) - return@runOnUiThread - } + with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { + val viewGroup = LinearLayout(context).apply { + layoutParams = MarginLayoutParams(MarginLayoutParams.MATCH_PARENT, MarginLayoutParams.MATCH_PARENT) + gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL + addView(ProgressBar(context).apply { + isIndeterminate = true + }) + } + + setOnDismissListener { + previewCoroutine.cancel() + } - viewGroup.addView(ImageView(context).apply { - setImageBitmap(previewCoroutine.getCompleted()) - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) - adjustViewBounds = true + previewCoroutine.invokeOnCompletion { cause -> + runOnUiThread { + viewGroup.removeAllViews() + if (cause != null) { + viewGroup.addView(TextView(context).apply { + text = translations["failed_to_create_preview_toast"] + "\n" + cause.message + setPadding(30, 30, 30, 30) }) + return@runOnUiThread } - } - runOnUiThread { - show().apply { - setContentView(viewGroup) - requestWindowFeature(Window.FEATURE_NO_TITLE) - window?.setLayout( - context.resources.displayMetrics.widthPixels, - context.resources.displayMetrics.heightPixels + viewGroup.addView(ImageView(context).apply { + setImageBitmap(previewCoroutine.getCompleted()) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT ) - } - previewCoroutine.start() + adjustViewBounds = true + }) + } + } + + runOnUiThread { + show().apply { + setContentView(viewGroup) + window?.setLayout( + context.resources.displayMetrics.widthPixels, + context.resources.displayMetrics.heightPixels + ) } + previewCoroutine.start() } } - }.onFailure { - context.longToast(translations["failed_generic_toast"]) - context.log.error("Failed to download message", it) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentInfo.kt new file mode 100644 index 000000000..3d04f0f32 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentInfo.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.features.impl.downloader.decoder + +import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair + +data class BitmojiSticker( + val reference: String, +) : AttachmentInfo() + +open class AttachmentInfo( + val encryption: MediaEncryptionKeyPair? = null, + val resolution: Pair? = null, + val duration: Long? = null +) { + override fun toString() = "AttachmentInfo(encryption=$encryption, resolution=$resolution, duration=$duration)" +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentType.kt new file mode 100644 index 000000000..15a947f90 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/AttachmentType.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.features.impl.downloader.decoder + +enum class AttachmentType( + val key: String, +) { + SNAP("snap"), + STICKER("sticker"), + EXTERNAL_MEDIA("external_media"), + NOTE("note"), + ORIGINAL_STORY("original_story"), +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/MessageDecoder.kt new file mode 100644 index 000000000..886d544c3 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/decoder/MessageDecoder.kt @@ -0,0 +1,144 @@ +package me.rhunk.snapenhance.features.impl.downloader.decoder + +import me.rhunk.snapenhance.core.download.data.toKeyPair +import me.rhunk.snapenhance.core.util.protobuf.ProtoReader +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DecodedAttachment( + val mediaKey: String?, + val type: AttachmentType, + val attachmentInfo: AttachmentInfo? +) + +@OptIn(ExperimentalEncodingApi::class) +object MessageDecoder { + private fun decodeAttachment(protoReader: ProtoReader): AttachmentInfo? { + val mediaInfo = protoReader.followPath(1, 1) ?: return null + + return AttachmentInfo( + encryption = run { + val encryptionProtoIndex = if (mediaInfo.contains(19)) 19 else 4 + val encryptionProto = mediaInfo.followPath(encryptionProtoIndex) ?: return@run null + + var key = encryptionProto.getByteArray(1) ?: return@run null + var iv = encryptionProto.getByteArray(2) ?: return@run null + + if (encryptionProtoIndex == 4) { + key = Base64.decode(encryptionProto.getString(1)?.replace("\n","") ?: return@run null) + iv = Base64.decode(encryptionProto.getString(2)?.replace("\n","") ?: return@run null) + } + + Pair(key, iv).toKeyPair() + }, + resolution = mediaInfo.followPath(5)?.let { + (it.getVarInt(1)?.toInt() ?: 0) to (it.getVarInt(2)?.toInt() ?: 0) + }, + duration = mediaInfo.getVarInt(15) // external medias + ?: mediaInfo.getVarInt(13) // audio notes + ) + } + + fun decode( + protoReader: ProtoReader, + customMediaReferences: List? = null // when customReferences is null it means that the message is from arroyo database + ): List { + val decodedAttachment = mutableListOf() + val mediaReferences = mutableListOf() + customMediaReferences?.let { mediaReferences.addAll(it) } + var mediaKeyIndex = 0 + + fun decodeMedia(type: AttachmentType, protoReader: ProtoReader) { + decodedAttachment.add( + DecodedAttachment( + mediaKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = type, + attachmentInfo = decodeAttachment(protoReader) ?: return + ) + ) + } + + // for snaps, external media, and original story replies + fun decodeDirectMedia(type: AttachmentType, protoReader: ProtoReader) { + protoReader.followPath(5) { decodeMedia(type,this) } + } + + fun decodeSticker(protoReader: ProtoReader) { + protoReader.followPath(1) { + decodedAttachment.add( + DecodedAttachment( + mediaKey = null, + type = AttachmentType.STICKER, + attachmentInfo = BitmojiSticker( + reference = getString(2) ?: return@followPath + ) + ) + ) + } + } + + // media keys + protoReader.eachBuffer(4, 5) { + getByteArray(1, 3)?.also { mediaKey -> + mediaReferences.add(Base64.UrlSafe.encode(mediaKey)) + } + } + + val mediaReader = customMediaReferences?.let { protoReader } ?: protoReader.followPath(4, 4) ?: return emptyList() + + mediaReader.apply { + // external media + eachBuffer(3, 3) { + decodeDirectMedia(AttachmentType.EXTERNAL_MEDIA, this) + } + + // stickers + followPath(4) { decodeSticker(this) } + + // shares + followPath(5, 24, 2) { + decodeDirectMedia(AttachmentType.EXTERNAL_MEDIA, this) + } + + // audio notes + followPath(6) note@{ + val audioNote = decodeAttachment(this) ?: return@note + + decodedAttachment.add( + DecodedAttachment( + mediaKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.NOTE, + attachmentInfo = audioNote + ) + ) + } + + // story replies + followPath(7) { + // original story reply + followPath(3) { + decodeDirectMedia(AttachmentType.ORIGINAL_STORY, this) + } + + // external medias + followPath(12) { + eachBuffer(3) { decodeDirectMedia(AttachmentType.EXTERNAL_MEDIA, this) } + } + + // attached sticker + followPath(13) { decodeSticker(this) } + + // attached audio note + followPath(15) { decodeMedia(AttachmentType.NOTE, this) } + } + + // snaps + followPath(11) { + decodeDirectMedia(AttachmentType.SNAP, this) + } + } + + + return decodedAttachment + } +} \ No newline at end of file From 5a47e04093d05b2f811a10475421a067220b46d8 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 15 Sep 2023 00:53:42 +0200 Subject: [PATCH 053/274] feat(notification): new message attachment decoder --- .../features/impl/tweaks/Notifications.kt | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt index cd5d09acf..71c8703ba 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -30,9 +30,12 @@ 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.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.hook +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { companion object{ @@ -183,6 +186,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } + @OptIn(ExperimentalEncodingApi::class) private fun fetchMessagesResult(conversationId: String, messages: List) { val sendNotificationData = { notificationData: NotificationData, forceCreate: Boolean -> val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id @@ -241,20 +245,25 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } appendNotifications() } - ContentType.SNAP, ContentType.EXTERNAL_MEDIA-> { - //serialize the message content into a json object + ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject - val mediaReferences = serializedMessageContent["mRemoteMediaReferences"] - .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray } + val mediaReferences = serializedMessageContent + .getAsJsonArray("mRemoteMediaReferences") + .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } .flatten() - mediaReferences.forEach { media -> - val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() - val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) + val mediaReferenceUrls = mediaReferences.map { reference -> + reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + } + + MessageDecoder.decode( + ProtoReader(contentData), + customMediaReferences = mediaReferenceUrls.map { Base64.UrlSafe.encode(it) } + ).forEachIndexed { index, media -> + val mediaType = MediaReferenceType.valueOf(mediaReferences[index].asJsonObject["mMediaType"].asString) runCatching { - val messageReader = ProtoReader(contentData) - val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { - EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false) + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(mediaReferenceUrls[index]) { inputStream -> + media.attachmentInfo?.encryption?.let { EncryptionHelper.decryptInputStream(inputStream, it) } ?: inputStream } var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! @@ -279,7 +288,8 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } else -> { - notificationCache.add(formatUsername("sent $contentType")) + notificationCache.add(formatUsername("sent ${contentType.name.lowercase()}")) + appendNotifications() } } From 9cb9bd7a26c3ebeab1a26e6b2d52cdb7edc4c46b Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 16 Sep 2023 11:56:41 +0200 Subject: [PATCH 054/274] feat: multiple media chat export - optimize message exporter download - optimize zip download/extract --- .../snapenhance/download/DownloadProcessor.kt | 57 ++++----- core/src/main/assets/web/export_template.html | 117 ++++++++++++------ .../download/data/MediaEncryptionKeyPair.kt | 23 ++-- .../core/util/download/RemoteMediaResolver.kt | 31 +++-- .../core/util/export/MessageExporter.kt | 101 +++++++-------- .../core/util/snap/EncryptionHelper.kt | 73 ----------- .../core/util/snap/MediaDownloaderHelper.kt | 81 +++++------- .../impl/downloader/MediaDownloader.kt | 52 +++----- .../impl/downloader/decoder/MessageDecoder.kt | 44 ++++++- .../features/impl/tweaks/Notifications.kt | 35 +++--- 10 files changed, 290 insertions(+), 324 deletions(-) delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/EncryptionHelper.kt diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index 698319e75..5c63f93d6 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -23,17 +23,14 @@ import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.InputMedia -import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.core.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 @@ -110,14 +107,6 @@ class DownloadProcessor ( 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) - } - @SuppressLint("UnspecifiedRegisterReceiverFlag") private suspend fun saveMediaToGallery(inputFile: File, downloadObject: DownloadObject) { if (coroutineContext.job.isCancelled) return @@ -202,24 +191,16 @@ class DownloadProcessor ( 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()) - } + (inputMedia.encryption?.decryptInputStream(inputStream) ?: 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) - } + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { + handleInputStream(it) + }) } DownloadMediaType.DIRECT_MEDIA -> { val decoded = Base64.UrlSafe.decode(inputMedia.content) @@ -359,20 +340,26 @@ class DownloadProcessor ( 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.find { it.fileType == FileType.ZIP }?.let { zipFile -> + val oldDownloadedMedias = downloadedMedias.toMap() + downloadedMedias.clear() + + MediaDownloaderHelper.getSplitElements(zipFile.file.inputStream()) { type, inputStream -> + createMediaTempFile().apply { + inputStream.copyTo(outputStream()) + }.also { + downloadedMedias[InputMedia( + type = DownloadMediaType.LOCAL_MEDIA, + content = it.absolutePath, + isOverlay = type == SplitMediaAssetType.OVERLAY + )] = DownloadedFile(it, FileType.fromFile(it)) + } } - downloadedMedias.values.removeIf { - it.file.delete() - true + oldDownloadedMedias.forEach { (_, value) -> + value.file.delete() } - downloadedMedias.putAll(extractedMedias) shouldMergeOverlay = true } diff --git a/core/src/main/assets/web/export_template.html b/core/src/main/assets/web/export_template.html index 7774b7af5..fe5b3c2c6 100644 --- a/core/src/main/assets/web/export_template.html +++ b/core/src/main/assets/web/export_template.html @@ -122,11 +122,16 @@ } - .media_container { + .chat_media { max-width: 300px; max-height: 500px; } + .overlay_media { + position: absolute; + pointer-events: none; + } + .red_snap_svg { color: var(--sigSnapWithoutSound); } @@ -140,7 +145,7 @@
- +
- + makeHeader() + makeMain() + + \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt index 313848d59..79a0fe94a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.core.messaging import android.os.Environment import android.util.Base64InputStream +import android.util.Base64OutputStream import com.google.gson.JsonArray import com.google.gson.JsonNull import com.google.gson.JsonObject @@ -21,11 +22,7 @@ import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID -import java.io.BufferedInputStream -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.io.OutputStream +import java.io.* import java.text.SimpleDateFormat import java.util.Collections import java.util.Date @@ -34,6 +31,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.zip.Deflater import java.util.zip.DeflaterInputStream +import java.util.zip.DeflaterOutputStream import java.util.zip.ZipFile import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -172,17 +170,15 @@ class MessageExporter( mediaFiles.forEach { (key, filePair) -> output.write("
\n".toByteArray()) output.flush() updateProgress("wrote") @@ -191,7 +187,17 @@ class MessageExporter( //write the json file output.write("\n".toByteArray()) printLog("writing template...") From a82c9d1738769f3ea69042ec59847c25d4a1d4fb Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 21 Oct 2023 21:44:47 +0200 Subject: [PATCH 152/274] feat: patch manager - LSPatch v0.5.1-390 --- gradle/libs.versions.toml | 4 + manager/.gitignore | 1 + manager/build.gradle.kts | 83 +++++++++ manager/libs/ManifestEditor-1.0.2.jar | Bin 0 -> 104607 bytes manager/libs/apkzlib.jar | Bin 0 -> 207751 bytes manager/proguard-rules.pro | 2 + manager/src/main/AndroidManifest.xml | 20 ++ .../src/main/assets/lspatch/dexes/loader.dex | Bin 0 -> 1083544 bytes .../main/assets/lspatch/dexes/metaloader.dex | Bin 0 -> 10392 bytes manager/src/main/assets/lspatch/keystore.jks | Bin 0 -> 2158 bytes .../assets/lspatch/so/arm64-v8a/liblspatch.so | Bin 0 -> 219456 bytes .../lspatch/so/armeabi-v7a/liblspatch.so | Bin 0 -> 168056 bytes manager/src/main/assets/lspatch/version.txt | 1 + .../rhunk/snapenhance/manager/MainActivity.kt | 109 +++++++++++ .../snapenhance/manager/lspatch/LSPatch.kt | 175 ++++++++++++++++++ .../manager/lspatch/config/Constants.kt | 8 + .../manager/lspatch/config/PatchConfig.kt | 19 ++ .../lspatch/util/ApkSignatureHelper.kt | 146 +++++++++++++++ settings.gradle.kts | 3 +- 19 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 manager/.gitignore create mode 100644 manager/build.gradle.kts create mode 100644 manager/libs/ManifestEditor-1.0.2.jar create mode 100644 manager/libs/apkzlib.jar create mode 100644 manager/proguard-rules.pro create mode 100644 manager/src/main/AndroidManifest.xml create mode 100644 manager/src/main/assets/lspatch/dexes/loader.dex create mode 100644 manager/src/main/assets/lspatch/dexes/metaloader.dex create mode 100644 manager/src/main/assets/lspatch/keystore.jks create mode 100644 manager/src/main/assets/lspatch/so/arm64-v8a/liblspatch.so create mode 100644 manager/src/main/assets/lspatch/so/armeabi-v7a/liblspatch.so create mode 100644 manager/src/main/assets/lspatch/version.txt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/MainActivity.kt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b331b52a6..e7df7f124 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] agp = "8.1.2" +apksig = "8.0.2" +guava = "32.1.3-jre" kotlin = "1.9.0" kotlinx-coroutines-android = "1.7.3" @@ -33,6 +35,7 @@ androidx-material-ripple = { module = "androidx.compose.material:material-ripple androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling-preview" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "ui-tooling-preview" } +apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } @@ -40,6 +43,7 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro dexlib2 = { group = "org.smali", name = "dexlib2", version.ref = "dexlib2" } ffmpeg-kit = { group = "com.arthenica", name = "ffmpeg-kit-full-gpl", version.ref = "ffmpeg-kit" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } junit = { module = "junit:junit", version.ref = "junit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid-android" } diff --git a/manager/.gitignore b/manager/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/manager/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts new file mode 100644 index 000000000..9ceba5b0a --- /dev/null +++ b/manager/build.gradle.kts @@ -0,0 +1,83 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".manager" + compileSdk = 34 + + androidResources { + noCompress += ".so" + } + + buildFeatures { + compose = true + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.2" + } + + defaultConfig { + applicationId = rootProject.ext["applicationId"].toString() + ".manager" + versionCode = 1 + versionName = "1.0.0" + minSdk = 28 + targetSdk = 34 + multiDexEnabled = true + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles += file("proguard-rules.pro") + } + debug { + isMinifyEnabled = false + } + } + + applicationVariants.all { + outputs.map { it as BaseVariantOutputImpl }.forEach { outputVariant -> + outputVariant.outputFileName = "manager.apk" + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +configurations { + all { + resolutionStrategy { + exclude(group = "com.google.guava", module = "listenablefuture") + } + } +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.guava) + implementation(libs.apksig) + implementation(libs.gson) + implementation(libs.androidx.material3) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.material.icons.core) + implementation(libs.androidx.material.ripple) + implementation(libs.androidx.material.icons.extended) +} \ No newline at end of file diff --git a/manager/libs/ManifestEditor-1.0.2.jar b/manager/libs/ManifestEditor-1.0.2.jar new file mode 100644 index 0000000000000000000000000000000000000000..7dc59a7b8047fab5cb6d2407f3080bc1c6577e2f GIT binary patch literal 104607 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

Of8eP>SknxoAH=f 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%0A$2BDqpn3oSn~FCa#EyCs6^AmtwhnJ%t+$G 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 IfQdo>4}5qn5&!@I literal 0 HcmV?d00001 diff --git a/manager/libs/apkzlib.jar b/manager/libs/apkzlib.jar new file mode 100644 index 0000000000000000000000000000000000000000..ca2dea2e699194028bfc424453367a9f445d93c3 GIT binary patch literal 207751 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 z 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≠dSG>dg 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 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/manager/src/main/assets/lspatch/dexes/loader.dex b/manager/src/main/assets/lspatch/dexes/loader.dex new file mode 100644 index 0000000000000000000000000000000000000000..5e06ac441051b4f88fec65c3a8619577ff371bd9 GIT binary patch literal 1083544 zcmW*TWsns|y8z&W2iQeIAOZ;%BuH=w!QsQ*U4y&(;;^{8ySux)ySvNc?)Ryis;8*k zb7rQyU+JFxk@^ifM@ye6dmt=J7NB{i)e+NTj1+xAb`2XJziyR0P85Ia@j2;MdUlIsxjv5Fg_$OQ-NwPp7tbe$` zu!w=cm*wFDUkV2YrsjEBU^Cm;$pMb=j4%9}EP5a_eL2frzK}V2^gtn6v5b#=BW8-|fw=rZ zN-~p|B9x^X^=U;n1~8iGEMWydrgR-SC`4_V(t%#AWCw@1NZ_yNf#1nPbGERb(_H2S zkyAwv#3wPC$U|8MGo5XeNgX}VgraHuOCQEDk3C!_jPz;EgEov|60=yzR`zj$Tf8OO z-_ZlfNKH{nQjsdOqzfSoU=X7i%W`(|l%LZ@4D`wOOk*EUiJKvM zAQuf7#yYMMDP#0NS}GF4JdW{}-!nxI6r=^?*v37AGkZQY>CZCGafxd@AUI3(Kt}2^ zfFaCa0n1p)TDGv8Bb?(p4|&NaB4&*q2;wLHAQ|cShddOe95rY}OFGh%L5yZ9b6Ltd zcCe2VoZ~tVdC6xYW|P1CN>Wmjk$=cX5lT~)dNiXg-RRG7CNhU5tYQm$IKnxu^MDt8 zBGNz612Kq80+N!N%;cglrKv(4n$ngo^kpbxnZg{FvX-sv<0$93&O=`Co0@<^kgR=MJYoS>d=HXbfphN7|T@Vu#B~A zVIRl2z%3r}k`IK-WzPIa0+N!NjQmS}icyxT)T0UQ=*~b!GljVeGUbbfXW07|8^tGmn*QVK;|3&INArh-bXv8$T4#tN4SIWFi|m z$V)LQP>Y7Nr8@%{!gyw}n3b$$I|n(?19{7di zq$e9W$V*LHGmxQ-VG1XRP}p~A#thC7xrkb#Iny~s#G<}O6Q*&BD8=*`+OdeG93@S0 zv7jSe=|LX`Fqq+tWim5a%qljpjXfOT1ZTLwb?)$(r@Y}4;Y!F!ej+I;NJB=llb6Dj zp)$2-Kr2ER$ONV{pA~FmCnq@14ek-fD?Smvq%#O{_>Dv)B^4RTNnt8blRDI=2_5Le zaF(!x<6Pn{FZjw2rQ{(=Nk?`HQihtep&xVD$O#_sk(i~O%g91W>e7)BEM-4oL?|O? zC`b)@GK-Cz;|(#(>T?vNHeDFUMz*q(cZ4e^7W~MsBqj~HDMfXf)17_{V*;~S&Q^|b zgI7c>FRmmaEmwhsaaWcqvLLDo~9&w4faw>CG7CFrOu?W&>N; z!5)rqoHJbH8n<~s7%zFpSHe|N8wB$c@kzp8q$eA>C_qt4Q<3V_r4h|&MLRmtjh^%+ zlp%~^0#lg75|*=;4QyctyXjV0&M};JRm8KZa{`&jP9FY8QA$ysD%9jZ8q6)SxL{7{p{2vx&nT=M0y)!9AYvl8*#xs8M1OmqeuCZ?cku z+!Uh%m1)jsR`QUiyyYViYFZmokeTe{rXb~LLI(yjf=R4mJIA=j3%(MomcBto@>7kb z^kO>e*~uYp@{S0#)d#0}PSiTqj-(W$6=T`JW!@90D{pDZM3!@qYecLk_eoDq8Zwxf ztYse;ctqU)^lAqCRWjDvU z#A7}a+|Zs+Vv>`Y|51dJl&1;}Xh$ddF^WmdVli9T!CsDXimN>5J8>F`F?lIU4O-BR zp-f~I+d0fhuJeo!L}(m6kdVJAK`q)dfEjG$DAAk9LrU@=T^Pu8R&km8d>~v?wZh+I zAS-z(M|FBIkg3dM9?RLpQEu>%FkbSOFGO#qE=Wcy(vg+il%P8Q(TWg8GJ!d)WHZOO z$z7iEg(%JKjie+W#i_|C=5dUd1X_3(QlGlx~b?DZ4q&XQFoU{!bz5(}?EuVg%!u!V)&IgJaw!LT7si>Bz)CXshGV=WLT@!dBf2n#1uWqp zmv~CFKHh=&gG`jBE?t?(VzzONTYMpAUo}Hcs?dZEgff%0?B+ZV`Apn?&RyiC9RJal z5QZ>@Y0P64o7uy8?(&Mr{hi}TPi~4*iF$OPFQb^w5;m}pGhF2Z(FVvp(vpKxRG~fn z8OJiN@`8w=&H|*PGX0pvMcxx{pgbf$mFdb<)^LyuJm)i!2N^$^C{JsKGli>sB<^6( zq9_gM$#CX#m`8jh?htzfIjBqv`Z9najAaR{Si

S+Yr4Eg0MJIX_%1}l#foaTTF)P`~Hui9nz;t;-HcC>C zDzu;vLzut}R~2Si=sEaDltLqcx#SVJ(Nb&3EF>(-+B04cah}nXF(3hq=T9 zUK4)4y@TZZODXEnkx(YGn5`V=CU1$dK;I%U|5Aq#Cb5|1tYs71xXgFrEY#~LO?_H0 znYnCX7gu>f^hL%+PD;{%&h+CAL5uALl%zhr8N&j062>=vUgDYLpe(KF%VcJ&Qe28qt>p9OV*^`9S0q#!q@m(Si_$Gn(;CW(IRv%nH`9k)7=4 z2q!tuRc;bxrM#m8_2|r4X0n?rJmouSSLv11q648!V=3D>&jUUad$qn#KFZRX{!C>( zhq%NuBCOFzNKFnZ(1;dvpfkf6%|iBbm=j#!3OBjO6Q1*i4}2xuT6shQ5|fS#!|j5~ILCGF@QCNU;S=FD7zZ(l#~=Jj4hm478q}jJ z!VQ%h=vSd17At9)!t82W^kGBq}%3q3}+=Lc}2YK z@`(lvW*c{jv_sBOiq=eI8z0HC)7%JQE%*3km*>)pb=)D+ZuvkF8ZeUeT;T;ldz@j& zLTMV%oB3?z9FO@ zM&twXh)firE}fXh9v%|?p!-mjUM%1^Z%J@S?$DS%Ol3VMdCX6T#gF2&WE9J}O5lk6 zr2q}-!%X&bmq2lG-NDCctzUFYM32FzM?8G-m}b$Z$i?WfqS~a8upTgO%JQ;Vm&>2s?RA%G=(h7|BWE z-SK?p@rgoroqsq;&^=?O7khX^viowKaqQB>E3Qf!uUtE~kkeH7HPo?yTb>38DoBO3{;8IfZJ1%4w9xhYFyx-pfl+$3`Bpuk_0p*>^S$T6N1>BpczeA1GKy7Xig zJGn!oI6;A5$w?Vn(w9l>;S%qO^;1wF9r>w8M@F)Qi-hrOT+gBqwdu?VHglBc{1`7N zkd-R*Vg@^TNbH~G0_Et;7}j!*x5WCzwbY_NYdJ|6(SDVGl%OF4SMJq4&nYeJdE7VaW(0_mwpcNVda`$YI%>_|^xnlg;_T;Mgqe*^_m zQ-}^s=QuBjnNUtroe+jIgLNDuj3|l3m8_Jf8KKN(C+B&>cYaPB6!?eAbY%vcIKu~G zCkYCqq5w5$&k$y_p2OVa1F@3IH}X-H0Ze5%yExAi0)NT{GE$s+^kzIu*vUDb5H6Y8 zAseM>L>GoIj~$%hG2xQSACgj-8nk5~Gg-p{Zt;o;Da3*d6r(fq*~uB65-nv=AQ4$8 zMs3>ApXqGiA|FZdmuE77m7M1bsZ&`G2C$mzL`p3OC`%Wnu$fDIAz>QzMrUSnmQVbd zHYiY#mQ3LwpZN1{v8E5JI7=X%aZ{UK%wjiph?L%VDM1@1u%2_gAx;L@QkgDHWh+9;(oWp)6oOZ}>5j`B9n{3}g=bxWNY^XATPdLQ3+{mI>_Q4Z&H&hKjUf81q=q zS)THpU$feC$VpLZ(uq*!v6YC~%$2Up=RQII=-qT+J@5G^yZN%2&*c19ow1#JM9ZNE zQG z#exy+=PQ|u=`YOY60wVm0lnD3Gm@21=d@=k*GW`T-7}Z-eB;kj@`+w7V80`MJEG31JqOiCaNmqdy0TTG2RZ$7UYzS0y#d z7|!u?Wwp!{u92{ceUmxdBW6|ipf}t3K>BLt#RRqzMuO_%M+atcjPNz|3Mw$2Q$(yO z4m4#NCy7u?PSAuY9OF9~YCD54jstuobscNP5VjDpuJxlHBiO}j^3^jI7IB83|Fh51 zn=QPhWPSC-S8_D)eHL+zKN~uOu!=7fZX`ZjBwAy=f>vxJdJ}8N0^U-fshVRiF`N0B zUaaER=Dy1e?(tU(djRveN&J@9i%}fsk5=Z(M9vbgwLZ!?ju5Vmxzdh#+~oJR_Hw3j zn_t_xC*yfY*7iYx!CWOl2R)4eoaDET&ZaEqI{$WZ9k0mR*}0Od#P6a`n8jVPbPWnL zXAaj%*v2j1qN}2v?20_a6RN9v$)PLJ@qptbBZ5&>HqX%1&>METRyObIDI^y zJ;d*8O&Q4z{_dwPI7+zw=0Gp@5^I2XFq&J$3)PDl&Uq3JG;UV$i2{T4K+f^UV6kE) zAILYvI&zxdhRQ(}^NN3m>4_ZX1I31m2WJTy;Y`O$9+GIJ__3c~M_EU9@PQ1Y^$V78 zi=W3B0~>fs(y`9+EZ{5U$EgF3ka)bkgGs!g@C0?pTS`n66ZR8+l6+?W{DtQgI4B5gK)8Z(Q_B-`RU zgmRh)TkUx?WF$KXJ_wM7N<$D%XO^g zD}{GkAFhyWk6y|qBJ9VQ3oxx66fL1#41lIW0{ zVj~d`yPmEr{U#VYOu!2j3do3@iNFPoT@r`w+GwXOmrnjzR0S`$2PJdu7w}}1THPmJRi#f|X z5`Az!p)=Fj&1({Vv=xv1A^X2>z-M(}6{tBhEML z#0ZWN;k&(osodw+K(N0B1_%0Zm>`%S&Ud8}ePr?|^U(!~i5G-M!)ILck3 z{bZ~Zr53H}#{?F#nd98#HBsUkGe41-^yJ}x6rmKgXhR=HFpb%)<`CC-O{{pqfdr%= zC*`O?16nbdxh!G@N4dm9-t(QGes(>%DN7^zGL8joXAh@&$Oj_-;u^A(k0uOe8BdA$ zt9en9rgUd4%Q?syE^&vq#QiNe@E18LOB4DroB1qdJBPWd}r~jARls*~ku#bDH~nA~Q)Hzw4e3B%#xsviTq2Atf9VtCrU)geL@gT8 znW0Q%5zAS}Ue0loHw03tFMcBpxu{4(+R>Y#%w|3-c*1Kw@Rc~JUC&>nCl3WELP^R~ zg?hB43w;^RB384D6I|dvUkOfQy~#oe%F~qoOl1uRI87LziJUe#kcw)I<2Gsk784He zmOs*2J1WqSZj56dJ2=TbUJ^0A{g6~-q9IdQz)mjlp5P2>hFr8^EVEh32KIA>XM87S zM(a%pnlq5i+~&tj#zzTi(T@I%V?J9r#8ti$C9~WlD}|^)V|p@yS*&I+mwChoqGfSD zAQeR@%~-DSln=zr>T{~lhQ177Fe4bt6y~s!ot)t&k9kA5Y~n&pe&TojBn?@}O<~GV zm3lOzJv|x3Sf;apRcvNIC%MKGJ`mv_y@;RrgFi_{da_c4N;ISy?de7+qnO5GwsVI2 zydivc>r4!OAqiP1L>X$(n2z*i0&`i!N;a{FV_f1MVZ0^6zt)CCq@yGa=}A9^F@aeu zV=Y@bz)3D~k5_ynIES1i71=39b?VTN7IdaBgBi^f=CYja9OV{a#LX#Il%NqKS=Y%&U1^$yyi0z3&;=R zk%Tm4=YN!?I{(p@u7om*87yZT`#8lF9`c3A|1);tlY$K7raX;lM;}HogH7z`JokCc zN4^uOp!^^n2}nXJ((w;@C`>u3(}a%nVI)(T&1&{?n%g|*Bj1TuNW7^?DC@XS#KP*I zY}BMDLs-l~UXZSca}%SO$yRO>D5@`#ipun23TOCA{9?vT2n)H#PsKfpYII~QtGU3h zCCrZwtmXwtO3Ddp(28D+W;G{yOVm=HMLP0PgXRoo2Aep}Jw6k=v^|U*CPa= zvw*Ff zL|6JUoOx{J1h@E3g7Wq`{w5RIDMT?UP><$xr9Wet!*aH9m`mK@Ausq&lnU00_#`1M zS;#|C%20{=gfNHQTqa^gy@PyIq7|c9z%CvTy^>s@GWE$**}rtDqDOO<_oS=p>`HC= zGLGe}=Lom?p_;LhmfTdQ75$jVVonpKx}HQv3Q>*L3}++9xJ9%Y-XBO!d4@5Sg>2*i zr@788?h&YI-uyree&iPtl8nE}NhxYjhXypECGF@!9|kj$3Cv<4%UQ!Fc5;a0oaG94 zc*HYa@t&`QuVqZcBrfquL2A;Ig&gFi5XC7+Wol8MX0)R#eHh3{#xaFiEMysL*vw82 zaE!BD<`xfl#yh?eskZgvC*qTsl%yvcxhX_xs#1?;w5JFC8OAi$v4!0n^DMfjzP?rWYr7fN5MSljdkuB`t950B_P`@P?<*Cmo=5c`Ad?KikwILa) zNk?tC3fs9sq{i+=Wk#@?3q)>WJhWmC7YJ(VIdotlmw7>i zX3o-Nr5OLwnIX(z9S6C_OCmS7o@AjUZJ5Yr&hwfWEu3S>Pc=F*mi64^EiqdDsDkMzNd6{M63-D$N+q2CnjvVg;+&!XZv_oiLvBhA%|wXzwB^naM$3icpsS=tv)iGnx6UW;=&D&uyObg-D&8 zDM&&(@=<~MG@&P>nal!Kvy(Gi=Q$tvy|e4dP60|%lTl1#K5N*;X|D5#_eAbu4E#+| zDp8OAjASAUSj8dEbCbt>;fJnrmNaCi0HvuyJ=)TbvCLr&d%4U#J`t{)aS(%_Nkl5D zGl`vC;vK==)g{#kVJbVgMIgj^g_LBYFcqjpQ`*s)-i&8D^H|PS_Hu%&JR)2V=L3?G zg`(7?8C@90MAovG%Y^ZXXgys=E=tpg&WvFWtJ%(RuJMv6z3fROBQpi4#DBCPgb~bT zGy6HoHJ%aZ?H>HhpJXBrC8$ADx)I70ma&Fy?BOuidB|IS=;Its64H@};#8s*&FRV@ z#xR2=tlabA
HgEzt&=4=G8{Kjfhjt?9!w7P5;2T;w4ii9X0V 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

h6(TpJPj|y0(b|O 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

rmIh~wA&QxA9&uIT$!hO0UsOJ*L%_Y16E&Y9o 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

ZdVJkBqe=#UbNIb5w!`ua zcoqzI-l2J!Q56Q$dyhoU0R9NZgZ6(D8C5#(eOgDDAAtN^YDTp#;L-f%{)dF0=nBF< zx}s1yO@#w}ybfekTf$lhjnfYK?+c&!yHNaOR3$?_gvrkDG;cDhQlYcLJHb&D7a7$P zzrV08=MXa?E|?q_nN%L%n-m9`=;cJq^u8h@=kXOqx@8_;G02t|e8oj}{3V3@-IBuX zzWTyfz6Qd*ZbRWF-AK4B)L6LOi4y)8Dog&%r0V<134aWghg?Az@>LXm?W-hw;Hxa$ z;YJ8w`&J9%Ly5wz-WuV0XRR=kF9}>HoD*6vToGDDe$1q{d#i<;okZbdZ;fzXXsz(9 zFFW}&lk)p>2v>%33io@tgq2lp;XZE$`8%^ZE$cvLm0RvxWL9~ESyXMA=UJ4!&yW@V z&x-XVoBCbyvq64CtrGbStY_KPpUz~O*V$1oJB{mQ+8>!?$&cApvTroa%k1hl=!C|I ztia4pJ=!m`t1Mt4h2|G(6zcl<-2KJSOUFBd;d9+&r^((@zN+k1quSEXk`{ z=k_m9+;ICBg%h0H!b#2@;pM=6q(2n)^&Sf+I!}c+1OEvpIWL6$y_dq2z-!@Thh7aN z{ilQVg?+tN!v0=s;cL@FxWp++@l}c9gX64{Dr)%o$4aUg*jM7SlIjoM4OFH1T}j;& zR#tz>Jg=~l(QW1QcC{0_#u3gjxtuL`*;@~govCdWZFH9!}l zajK@4Xgu7aV)GI`OXNH6Agt0mu z`MJ7^)m4O*yhyNtFh+kM{9J!0`~v)1hiKiXuD;gy$sg6#IAINSR{ULq`WH?9tDy>r zziX(1!kVzVCi-1d+2^@xD*HZCP4%{nM@?nlORA~HK#q5wkiK`-IniGWdTPOMwP}8_ zKWbwh)Hn zey{@A6ub&{aBTTquH{6}Qqv#FS9ppLINV0zwjc=r~ z|Bs4PL*+U_B&|E$X}lw8-Qn}gNHx#tME;6Y|H<)Or25|ZnBq86edJ6N+WTCQbUas- z;v-W1=_@8Y<9q;qC`=5qY!a}H16u~IOwW5sxx8{;h5g6(QzF$^r!d7!r1Bho51DCb z{zR%#py%-W$82YQVSaCnu!EC<=6R&*0@~|4k#s!B&+A31OJFd-@B7xrxYSpP4qr#F zuhxP|;5zUpa09pzwAbIc{ursh226CuLbgnFzJ_d>=!}DGndpp%Y?}D$4QQR==Y$%lilF@-a0As$p0{hD>~lk;N1h*Qpss9s^D1I7|pBf4Wc%6lLyt+kAcP)|M66<3tm0SPlqxXO)o#Uky&XZfIQ@SLr zH?3&D`I78yr7q|l!YjHpwbM$C(1zx7D^)X;TbQE1r}e+JIxFq9R{gy0RG-Pu9knL? z&xQ8+qt?nkf7Du)kmrtC({WpG(#z#;k-pZdxIACfTG{7^S}XgUdmGr_2K#>-?Eh_) z&+z}v+M*v!-Ur&Mk9-9v9@-(D$?5Ho-X7^pPH#`~bA#f9c@sW?Wua4@l9A9?C@pLETccS(ESMuk3kl%wGP3!my8qa7- z=ka86zoO}SK~5?kt?q)4!6#{QF3ErBEEhgX;|h_JoxU{wtY@$=1-u6)JKsxsDwymn z5P5>^OVMgFIKj6^(kFvju7^e|lSY0{BU*WK-7i{&z)@*(vg8}@wlLtOpuA$oCkcRTvoZjv@Ux$m=89 zeFi*>bpCEbw7TW*Kyl9ODBLaIZHQKTz~<_?q|aOeGI#~)d_6Tf084^Zz^Y(P@LjMrSO=^N)&nEK`d|aFA=ns<0-JzM z!De79ur=5QYzrE7K>F1}wWRejT74kb^LgC)x;~G)rM=D{t@`_VBmEO$Z{PpeSL8d+ zKyZ*S#j(5x_LAp=qiH?cEcq|Nr}BI%uPa$;ALjfog}r_Igr9&+D&H-MR?ooy zK)dfptJ}_FsrM8Ns3#%^!GL;-d_EqCrsIR>!e`)9=QYxOv`=%sUzqIh^8jpb86iD! z2v!uPIF|RoJ5Cizx8GBVR_*0`D$%MF_#W6UREPGzXw?O57pg0Ar%*lNdtj$fBxK9z zP<@fxg&GUngPp+lzz(6Nk{=Bk`Hl;(gZy0=Uhh5<8t-FavNK$m3MM-vMD7*(T=+hi z>U@E6BZU+{;3(l8`92KC*JQ|3gsG0@9p@X!mUo<~kpIVNkf#fuI+mS7Gek~y773qt z{|KwAi%9p=e$4Ug2&>8c5#C=jBAvfO!}-O);=(&l8R1H{3 z&+;;){|Lr|%fS`kN-zOj1+E4Y!L?u#xDH$o{se9SH-ekM&EOVrE6CSf*xsMP9pFyz z7jPH&E4Ul{Q}{@}7riAcV@WU73BX5UInj#*TEYg-v{W5 z@#uV|P*@;G&)@oh|YbtC=bh1>n?$8Ku7e~qxHw_RAuD@J;|(e;|*!tH*xgUhiU zT%X(TMsa9)HCUGRg>H2HsytXhSjwv?EbDcE+!5>~jFVJsxA42~}>XDTH2<1Ok_B#U~L+*|8y-~jp z<|6@k^d<;K&4mneDr?0`Xcay@SeL=m_aQU z+T-Z~Xn%mZuKUt)@c=baHKX}9Kz#|00&DB$l3oXF2sQ$1>K2k;TelR}1sj5m!J4|2 zRvQ5=vx0Y1JNpdJQuQXCFYA(Knk+p8s{>oUSyx+?9*15~nZ zNc$ACk#KP!g4VwQYH{Fg;y~3%{5%kGJP7?Cq>j3Z+8v~hfe*n);Da!m{}4RlwvqD3 zL8JIRF4i-Ej`vuP_@)y>{urd*0&9h=+z`C#|4Q<2f>-^cAzRkdmJPtU;5@Ka$mTZ$kGNk; z{o`N4p#YP90${S zv7h`tSe26NK!ep+avf+0$_+ueAt*OQm638o)MzO;R6Q0wLsdz$md10aDsA}tM?>j4 z%^?~W=3(#%Sdhkp)4PE4h4#9648>IqIxmYwT*a#LW-86ASXBY62v!0kz_-D7z$#!> zuo_q$tO3>p-vw)dwZS@IU9cV)38se45c?{djlx{8|9}0sAGU|)Q7m0=+b@g*hl3-) z&%t`Wb2QK5ux`bX-#gOy#F1Z)g9FLGamxM|EsoYfSIS%e4O9QgcnrgRrQs?}t``hP zePW39u*`Z`W{yy=rQQgYOWGNM@+{kYTmEwu5PhE`|8rGQ^s&t4SZ02qd{XWURYdfC zf$}Wdd|Q5`3QD<=$RDX9L?6psj%DVT%8_zkqTH8ispw_d<}VeFQXwff3gt$r=Aw^f zF2^$SE9IARU!fe6(?=^$(nlkGw5lTYS+@CAr2ZJCq}&*F($D@JgYqnMIhL7YRXQm* z7WrdUKGDar&Ce(LzE;o0|6i*MqVH>zXPL{f%p9k*=o^RpajKx`W7*~x6n*2>3(+@T z-4=c0QJ!Tk$1-z*GNNw+@+YV_MIXyH|4q?1QN0v>6IDq3J`v?v=5j1ECn;C-O+x-8 zRa*41Z1YQtzRBv9=$ovviN47w&oY-|nK?y0mFuKa)HATY%)2S70oV|11U3evz$Rc* zuo>7KYyq|eTY;^?Heg$@9oQc10NVLJMRhd1eoawz12<`1n?m#WD6PL!ReJHuRLsk1 z>ap}^8r`R>K=CsT{hvnH$#YY`S?2yvQya`Np?ywin#vIDM*Gw>S{L}bHkFg}km;(E z8BN!dW?Gw6C|Uttf!>*)-2PuA5Ls(^Pu z9sf4Fm^)gTW!-Q1CM_28;#cz+vEUa0K`{_ysr;{1O}m zo)`bmRzt-9vuS;2zt4vMXVY;uU$2}E|IfyK8%}5c&sHho_t~nY?-2QOwrT~o21kNl zf}_Cm;(yk6o%XBQG!NLHb8vrl4%(fA_U6KmbLsgje$HX8vhVHARh{Mi-MOkO*d6Q% zz7Ku~ehhvB_66Go_L2SEPBt3fxin8d17pBgkl+91e189z$?yHnRiCID)Q@?va~|xR zryB}+M5Tv=fUoI%Gy0oS-a;cYxg{5?VhKs-Sd>Sd!Dj(v;KJKXZycH z``@WJ=RD1q`B*RKtA^sY`E>tgF_oW>@tu$LX})R|YC-GHeD#%CK>hh1dcH?oFMzxN z@59e^D4K-(^`u$B|d*xRQAd%tmp#NVN{Nr*(3X8g2HHpBAZ! z!NJr|<`Cf+={L)ZAa53K3iHvpE>hh?{|S45cD?2P^18c7T?Vg!SHWxGb?}^%PRic^ z&pGKKTOM!^k^BSv`va}(8|XNixe?q1YVr%GyI?0U8q7t0;rxlX1(7q@C1Lg}ehWy6l`NHJ+!sPk#1NoQVmuGny$n%HOSAZ*# zeiXb8-T)tf4}}wh{JuTcS0KN4&$1ydCO`7|&0@skV#MQO#N%Q$R^o24`WhSujt3`z z6TwN~6!05xDmV?C4$c5)f;P?`81sHH<~^tLykAW7?l8^c#We3MuYmu88G}zmyH72{y1z_yLVB~nLTR^I;0Ixcz)Hxg z!L33&|Cdoec-}L4-ZOdLFH?RmAI-yMbp5Xq7$N*$KkYuY4D*r6^O4E(k;(Ir$@7rO z^Nz{$j>+?m$@7lM^KKcsJ4)8d?p}16A+)=5345{ zw8vQqI9^G>@k#=YR}yf%l7Qot1RSp<;CLkg$1B{A@5MeF|J;v7ka-?(J|E8{C_5h# zRI>oj2TornY!~=R*ct2!b_aWc?}HzLAA_HOeZkg&4Wh4EfUnnZd%PYc(EQ?km&yAs zljk3k=O2^jACvc8ChxmU-ggt!1UX+xp!Wj!{RB?ueVEDnFq8LTChx-uwD0mh%;bHT z$@?&q_u&N0^8_4sb2{(KOx~B7ye~6(UrwOoO+Fq>pnZ?ebJ)*!gm%A9p!vYZx6BwY z7UX%$={#Q&Xnyd1%`&eOOrFP_&igf!=QF4Cy20digURa#lh+L|ope2UJLK(%Bj#F><|k2in=8 zW`tFxd9y=h3VufY-9d3UOK6{G+d+OGME&OUS70>7=?*o}vpnE#BzsuTMT*m%>Zr84 zQ`zsc?WFabpI_Ul?DyGrs$F^n&9|MTkH6E#zFTe4rG(ZFPM<~gFk6!yyJ636I$zyF^LaP)?&wVN@c+#Jh?A}M~ zPe!tLAD#C%7GC!46pm2mgopG~;pc(x$Y1-E{f^y!Wxt2FUmXxXvHUgpZ$J8dfcnYf ze}L@a=NS)R{5hS+pULCS`8@6i;g5r~PH@~F#B-7d@tou#I`90Q^c+UH!^l63{KLxr z-{J_h&v9}T<&L7EwoPa${ zKHoio>mMg*ocK8Qq&hG2`XoKyn?miJr2J+yjwjV_Gm!ND4tsxBIb@uFSI>R4Q-4oUKT^rQQ`C>g!ppuw)NhuJ9PZ-z4+;a7JYl`3$auoW*-yXCeQi{*d_n2j$Np|C~B4>E}@YoXReG&LOVP zslB=`tYJij&JUZX=0T zp!W*ezoO~|>XQB|SO-a#b?*xNc17(o?7u5ipVz-D>PVn6*?&cC4@40E#eMdF5f}fG zevXTOX*_==JN{L9WW4{ycyl>kFSs197gtqI@!wUFc^t2)Co+E5)G_JjHA*i<_FbcT zWrPRZBNT7f$j+G*XUso@`Fwv0_v=>V2Tnf-{w}=it4H=+Q%Cg$;nm=0WC!bSLGg7> zopg6e`}TXIl&*VGym7nbDc-JQ-MK;KIlgWnPHvK)c%8b5b?2r^FV8dIRQ2>K>fcSZ z-!PN04<@60GRohA-rMSl9KYOF#exN>pUi^7na)A*h_IYPDv#eW0p*AtaFV0l>d zK2b|mKGM(S-xQW}+LE82(766h{eMFD@6HIfhs~$>e4-YH@pJa9FGaXW{@eOg8L|7R zYU1Uka!+B`GuZV^-3fb_+JC0}fzlMW&#`Yk$A0r14g294~5Cjr$YO^+ZVJyw4-r-p^ljG6pyTj`4a8FMEft% z{!6s~679cKN2T4DX#XYMhvN2MqWzc3w*OMu_FvNTMBM&MYL}nWU~>Dc$F^(Re}(p6 zA?{zpj@LA=`MBPv?Y!`5d>265{ok)`z605TtWaL*V`8t;)?Z3ndzH5SQrg<9w6#}h zYp>F8dj%-onT5b+!ee4L>tp+sw(k>Y=-1G%pmxZko+I=^O@ z{2;VnSjWpj^ChTf%5&vGJ>B<($TPs{zLAhEYkMz6uI){rbv39btNb(`LGn*8nvX&K z6*wAPAGjpxgXMkCpiU?6cLvFidnlfQdXm~FoS^m#CxR2y0mznvooBQ@dl-ihm<~)2 zW`v(J>WM+#Z!_uKvQA}!oSE|ZI4E!X!~Ax z7Q{hT)MxVkkX2vQTrQg~D(z&`_PFB>T}tFP=p=Cmjc0cKmdM$)eGfT@t|W2}D$n~$ zPMuHWoTRrF#bYkrNaS3|&#jAzoEvuJ(RD@61ATdQgvfbOKcB8Baz4ase%O~E`U=4Q z0?=1bzbWMlYWrSzA>k9`QeE7!a7{!!qE4oE+q1s+P+8qmM$&wTd=2y&MR^e zZNKkPR7Z+j6#2z;b&-ofF0PAXAgp?`7r(>SyJ{QfoD&+lKCAU|CaMg%Vl zs|T+Lr#UIYSvXTn*|bKywul`t~sqqt^!=@)6l zpl=)Xi7Z!bG5LacPuPo$p&{s~M7Cq(C z(x*E-pIK&o<@BGTue>fI`pP5DD?qLQxgzvegj@-7CCHUAPbx!>K>HE;Z^PgDiqLLIhUglkG)UTZ;mzVLVtkn2HTJ;?O5q^tvxkn5vaUITqm#-oA0Amhjv+`6$ZY zM}BUC@=elYTfT|DEajV`d{doY#;FN8oN-J3ZTBdzfqwmt%XFY!8=Xd)n(};`a{vZLy<+J}L3r5q5Nh z+zIV=N|SF&`Ik2_Rzy6etYO)62B~S{8GNeFO%b! z%X9p4eU4vF=lJDxj^Ccz{x6{y?CM1q^Lbx^etv-RALxH2em_L{ z57Xp-rTm9FMaqAK@*nA@GEN^u{upv^T}}MZ8}cWRKY`o_`upg6GG2Ys((g&U^wD=E zUi#=I62DB27cR%~!sKz~avU#x^)<1(AMEI-?@GM%*EPhB{*XUKyPu}X52XE1bxnyE zPG`NJ>ieR10QAvc8fCp3i24K5g#0DsQP4k1Ka=qqm6rZY;$@V6D)BN(zm#}ka=dUkju$5T zh0AfgdxqBHRK{V3_Paa|Gf{q~E+XY;q5P~gxrmgXr5!0h8|7!~Sc$uD zQU2RBIabPltCf_WgYt8#i!F;af6)>3{R%FokxB~HIX`R~%?yHfr;ZKV8s zl%KCtr2O|N|9zUABIUo=u9RP(Gx>JY{91^53-xI^UseVVejmJW5|NqVGP2;o(?JUw`Y> zCEgb6M-rb)(irCQ`SKDSCC8IXbrUJKRCkv3ZE^D@#uHFw$IhZ>!6#L z^u+6o@_)y8+Rxt>Ig{H~7$g4?jMq!SjPAE|-6Wolzm^K^{g-%M-uEFLZ^zTVR*J5d z#B0wTC`<>Y2Qz^6e0k`)2bXUOHWQ|EqlL|UnvRP&pTFD6{7D#ew+rq6q}kr06rapu z!i;VSVbJAy5wAmTRl4rM{pA1UaJjKGKe)d$h4GG${2#CFeWK+Uhvjr!&fnEpt_yUyY<`fi`bqWdZIJapWR_LPMY4RKMHK-{bSZ)J$6PEJ$|G6u) zBmG*T(}C6xE41~)3T^wpLfihY(9KcqD0m(8`Pq*v^lsO3ALzgj=|Jl@*3bT9{g(gp zS98cm!Rw&UZ_Dj=E%$*z-w7Ju71{%f`_7A80xSuZ0&DrMNPcZ_wC|?KW5D~q2O>WN z_bNqkvqJ9!_k#z(gWw_XFn9#~*)>vsCwM;Wu*k){`@{rVr;}+OB+&UIzn8QMak5IU zmFuCa==vDP%_=%CtxWN|ae^&EbBq-Zm7!t2KRVMrhZY)wI9y{od7BZ&qu+??+m9Sf3@$Wszwv3NJ|iSEK){ z(SO#@_kCIaV4+=KR?~47|EHeq=KiwXmevoP&g%xJTVC*UoUO+CkqCbz()l=#Pa^!0 z2!C)o`+?If^NSx6^;@7F$3z`3eqepigYCDpe&KZX2d7(x9G-8~ z54r!o27XziPl#XEz%Ogy7fxq?aJpp$@y8ln1GIiwqnC?cSReak4fUV>vIc%x1HW)O z??-EN1@Q;#VZU%X#|M-BvPNeUzpQ~@*1#{UpZ&t+*e{&Uepv&*Fxf9_5HD;$`-APb zw0_}q_6Mh1W^;I*Sp&bUg-7p}*CS*vr2U)I7eoX-0&>u0~P%zk0AU)I78 zY%lkp?X|Ri;B@Xkr(5Q7Zi{T=BME*;qVqcTLlXRu1V3;(_n*@(?SB)K^d0d-64s9- zy;A(Z`q&RiWEcA(34Ta|AClmQBs$;V{<9v-%i;&F$9_oCMZ^zD@Iw;(!1~z_T#o&~ z>FfuVdHrB}x&LghrS$`+bN@NrvWUa`N)qB>9sID4)_wNFI@r4o?XN@ooX>jKq27Ab zTMxgjhu_xg39?UcI{S@f%dPG^BJToi9Ie+0;y>2M{##Gy4eY=5@ZWmavmX9iueZAF zxAl4#xXI=D#r4>K>tXkLy;S_T9`>_-_8*sH|8Y9|Z$13S_HzH$qn~WIl{dM0B;ERt z)7g)#pZC-C@W)2@W22s}rjQ*Q^*nI4x=+VL8+BV}2er3Rw*%WcJ0V-9^Nx|cN!#z6 zZ=!zldDUj<-K=|u7LeY}dXQcyT&N0=-pzUuxKOo+Y&lqW7WtD<7hx8!AjQdM-Nl(j z$EBNfH!#YXEpk(^i!&ejmT{1Wfl*Ep%3H=l9u7_lT$B81pf6wwb9)ZBKg<>R0O$(@ zk#9K#@;BiAuxu!AIXPfCRk#KI*`f#fW>MU1)#K%U{Z>5_%*-dV+2!vJaz1k#>ErtpOr8(h^laa5IzHH@=lJ#tSNZk{tN99$o^86C zuaU5>?>AwTjN>+4->XCVx9P9Ey27SjJ>jijS7DUL-zDFsqr4Hqnd(zI4%&`(x1-(d zX!mE#ub&YYJ5X*1uBYyVOn=Lj^RZtb{{ndz;%XP_|Ej0RJpGm8ov;7z)(yp9yOF;K z_4lCuUdVeP?}I)2P=3F*{{h$!eFwDtZ@>ZMAJo4{dk2w!NM|$O_~^f``VF;A*f+FN zc*a>RwBv9H?HW0FsUL#?RP)=Hp4wJt)ewiG>#xJ`f=1h0Xt9VW)dGKX#M8*icaXA$}*e#i28LxXLBDDf78dL z+;93gXxsaZ+PhBnF)vX&zroJm(9TKtg~@(7sT+H|E}YcOk{K;qbs( z;cw1jvgf33;XM#`^d1VQ>rv!a*2Df~J(g|3PT*Q_Xebl;X!1qgp;~8 z*amD1wgcOP9l*D|Vzf@3)EiVUino*cNZ2FcXQ5HFZl2VM>Zx!IxK=$AISI_J{u6n- znne5BNu5(Y6597NPwE`Mt#dsEx*+uqcUJ(vN^2xbNCf4jMz!8E_P9ZR2lSNFF5LE`JSKI8N!J8tXl%F^fow7%Td zM!!qz$!&UGE>MHa4#x2g#yJK3OToCM;J6?~SC@8DbPcd3I7F=>|8ltl z!fxIzif`7#>t~7%>V9NLiXI;7FC3%)5mpVfCA(6vuBYf1&QkJQicYV2TvPOOCqVY4 z=qbJ#!m5GJ1zEo7t_Bl@e+1WnYlS}reig<$ugG6_wI}W0LqG57?wa2_y{CJCJ;7dJ zQCYX{>lPBv_i=pl0P+LU!_P}Up!3nk6ekbpxbumyo^Kl2`9SXiuLd`ZY~Kr^a=x-; z&qL^WNc)%~KRncr z)rbAu-&B1RJO<`UF?SQ&g9tO8aAtAW+Q8eko;F4zEU2sQ$D`|pcAZQV!0 zeg4Nndpwp(@yEwwsWkuk2s8PH3-h=?3hjUKQtA5sb&v@-6GRmVdhaB%S_WC@kcjwB=>JNY$g$$lqm7)o0y1G!IhsY0om=$xiDA+t2TN zvi*ErDOJ~(=U!8FLvS=W222FkfZ^^EsaF_$6MPFS1{McPfF;3FU}^AbkiUzZO6x&$ zVSW1_KeC6v_e=c($AF38n*YhpU@2EmzUP~&*Erc|9Zc1E+#JHMd<$qjq497Z3G0LT zT>cJzs(uHI1oOH4{aa4Aj0EeuFC_gmn8)Ss-lpnAr;;$-9VIN}?i5ybkAO#o^?W61 z9pkv*@5`p@EB60{RG+^C%XVA-CjTo;)xU%GI$f%6r#xEkQt7xQB+TRT_gZ<~Iwox9 zJ5GF}H_86;gr4vIiTv^u>)TV^O4jA4NPkA@e0;&=b?;ePdQp@jv~muQrX-bG=aXJ%{`PdR{<&iFNd4nr!>=3guoQ{Wa`hvK_B=bN6$aH?MUI z_X}Z5u$jA44ufxcSWw z;4&~CTn?U>>$=0ld*3$C{j+$3!8T6O|kg^o-50DVH(%snZz|9|vTJQbpKo9R(pF$)W4dA)`9 zzmR?tFaL%0n|i(vX+E($P*~1sPjSJrWj$X<$nObv`&J0|_%;dc|0UUOe!tgm+Jl|I z^I?4@Jxjo{ojYCR4(<$;pDFC<&H;ZCwsSWKJGdJmpN9Mw_(EvcTeg?qL-iYbyyzf4 z9Ap3g;25R&KM*YU6DnmH@@^n~5&4bFS>6abFGX%+3U^0SyjVqX?x-v`M``Me)zHOpi@qO(|#zNbj* zKvtTMd4xS({_il$VKl$9n%1F!kiI(xek-i!dnG&)EJgcyHt1(^d}K4*<-9kW`5D{+ z?gaD7`EWLq56ll101JWPU}5l0aJv&r>m2L1ETk;M!S(tG+4qLA=e2LpJgp;g64G1g zSLD|>Olz=$XJvan{)VyVif zF61+L9CDeCn)i=frh~2l)&U!Vjln3e71#-UPgq$GqWH{ZT7n(GC%!=xFS*P!@Ud^O z$WOt?z9En;ANz(vwtVdS46@~8Ukqf+F5ag!e{z|gU>9!yWXmqzAd&m(AB6qD{$PYI zNPf&^+Gs!dFPCWtw$Tn`%eI<+B}Vy`b#+?bxu2FDz>b>t5%%L0a2>cGJOCa7FM*k9 z9nWRn)+dGUfR%L_n$O&hWe0G9udJjm0=s(UMD7J91dfoL+t~ZaxsAPlnFr&Z2kCjt z6}eB4$BYZ{_4hm`$-7VEmxs#p^`AU6zcY}Z^H7}eb3`oj`5Not`?q;a5idydn*Q!8 z`&(YbQ(n_j;y5qO?@wu7m?F+rTZ(P?5)nMv(rzW|#AT z#wVY#@2lk_`}saueq-Oq%MZB#`d9G(k>k6d$uIAt6*TjGS*Rb(tiouoJHj~6OL6cSU{l|hP8<<%aqj4!{a_KX|jQWCbzVE3plP+J_=PQK%7cwE8hx}K_ z3~-s@uqWKSEAuzp)B<~|L)4FO_&XfyNI2G!aAVhzaAVhzaI7QYSVzK*T}Q%=T}Q&P zj)WV#j)a>J+#EE1;pP*t57-y{(9I?JAAui(y}|cooewwt!2aN;-~iCBSK$;Nk3^qc zN4fn&)HXLzQ*lZC06~?~xril^xO~k=l zrjzVzZy9_4wTQ9T)ry$4KAz`AAs2;Q4018Z#UU4mTmo_l$R$m(teYjN-NH1!B~5g2 zB+cWJu&)%3`%BR}&CgesGUH_5D24peh=%lp?*1(FNgBwO-IpN-u&Q8 zrExBgdKJujl3u~YxH-x03MSSaPVk_Ts^G|p)b?A6M^G_2s6YzNBxW-JNURD!VHpeiNJmwfw+yJb^z&ia>lvAig6I-x0~)cM%o@4Q`3~9jfa0i%=`7 zZIrKre$+Acx!5|$uY>%$@IzhLTNn9tkzWt_^^ji=`Sp+=3H?moPa;t+66GRMu0H%& zANlo>Umy7mpohtN8lYSQlxu);4N;ECl5L}6Y*n!n|Esww7WGt))pUo*(f&95RiH-Gy6pyRCOi0|gGy9L^1 za=R^X9MQsrY2L?M7+3Rt-NFPwqxn3H%UPas>XKbtp7+}pCY^qh`rpEwc1jalnn}`r zOSIdP`a6N@w=^w$1*v>XvZFo7er#!aczuN@-M`5{T%P^Y(hODAg!G*wVr$sl8s{6W z&2Ty2Xl*9Q`9>S0x1s%nziY(&92`Y#i~P38XL3H1^V=c69rBr+&*c2}$ZwB)Cg(Fb zzXR;+fN~v7L0LySpgfbyGi~`1Vt)rSQS9%C@#u(ktfTqd@f{K=dOP6OwHOTYud|9qC#PaR!O65Bvy)zweae8MnDYS+9 z-I?n1e><21$^OpfYnT6b*x7vH&ZPO)*^HFu_&S>}!IAEAN&nidK=ya0dBM+hc7gsb z=8@>{VkV3JF3`{PlYS<*)5VN(=a8N*<}1<5WW8OXhskz!g}q(P6w%ie`k1Vzs~Iiz zyP7fJXy`c~wu9{JO2;b~gx1b((9dN3-C%Dw^Nr~52K`L7m&xsPGh;xmpMJzx)$?dgH`dzf|7eh)KE+V6q(d!St=_n*n_ z_dvTn%mitV$$sd8_IjYbUTCKm_O)JSsmxRQi-ybJb?9ZLfm7X2AzS9v6=*%}MeXz# zPL%tgz04$VGB_Qy*Ts9mZZ6k@=0PtrIgLCodYM~6OS`Z1GM&9Gv>x^{<3W4hklX!? z;=Y$@>g!4UVty)|>hgP;Y`5hMu&J*D?H63GlW>yD*A2LR%PHVD;PkZoEmF>2SMFsT z`HoL7v)TKN=20)>3q2It`ObcOv#@QS`2qayVHbM zgQaNQ`N-@DOHcjda;vF7%pa(ok5TVq^W2?4e(4SQ6ROYg@`)K9)`iBYkI66dx{t~1 z&q;dwn719v^r5<>uaBuJ>}&jCe>n7>IO*Z*kWALo*Z9KbQ@y^VHH8GzFu9#iP1!&-NiQ2%EvyWd4YZ|p zIe#YUV_qQ+KtBhVlKu%(=j~ z#vg!#g*2xGLS{>7N`epi^qUrsnc-$eQ&VLjh} z;0s~A^HMm*w~WRohW5!J6tB!V!lGVD>F0WPB>KBmNXr|I2lX>J3!Dok2`hRBA!ndC zk1-v6orG6|D}}|pE;RnJCX2*%Ec{2Z_%9azizPeQf3ff%ll{l(?7vuJ{TFMj|6<|4 zSXvj^f3Y;~?7vv}kL$7jVvY45ms=#V^<%8DevE}5$sX||>tR1~I{T6Ju^*Z2N7loB zj5XGe+z$JZ$$n(J*^jZt`Z1Q`hW!|8tRJbr;zuU?k;#6HHP(+TvmayON7l=Jj5XGe zafrh>_%Y7Rbdza5#hEw4Jc`3O_%#lGjidVP*Esl<$$sT@_G=vc8VA3|!LM=fYaIL< z2fuPX_G=vc8fUCO=4qe{y~HC#SPNSr7Y@$^PVa*q?FmC+lZ_GTEPTCMCF@ z<~`fZevN})*$?d3IQW(ND}H6NUzzOJIQW%i_G=vc%KF)_aq#Of^N#a7tt-P!?y%Fs zTw#9+?K;8v8>l_b&r_J0D=aT@IQ%^v{vJ+x+26zAZzlVj)7js{;qT$__i*@oIQ%^v z{vHm0b3OL=aQJ&T{5>509u9w#9pZ1U&;I6g_BZQce>2(N+z$JDIQ-4}+22g|H`~j8 z9u7Ybho6VT&(u%xGn4(yWIqpwpIK%<4~L&wFZ+2o{5*p6@qRVJ^pSO!Ih5{KjzBzf zemRQc5!4UfADFzJkHC690_*wbRF2p0Fa95^P`xkA^x#xl-@YI_-lKL$BL7Pwx69;u zUz!aeK3@6Ke4xK4jzT(fAdS-~Q!+3}c)%SjJQ+5G_!Y{1WhTjfJsR?8GdU~=wLbI!JLjp2~55nB1T7bX?Jp+8YnM##6m|*j^^vG0m*^ z&n17d{G%{S;0M}wr888H$8=l|n1OmT$X`*^{tWWhX!6%glfggR$F%xqns8~K z{)Q9Qh1#EKriFDCMtVc3otdTy*ua}3vK^P1=9Jfx#$zVwj}_MQ#X%k>yzCn;Oy}=L z?awsXe18d({ME?MGtD~iCvXG!xwBjHKX>*D)A`F#KWEZ9FkYA%nPs{L>78ZP`fCc;`0Em9BY(E35?V*`Hrp&y zPssk+G=BZaZsrc*IJH}NH5fzwoK5Fh#b`b=&kIKdo`S#9eEQb(lyUi%^7+2f9MdZ} zjr>2yyf5iN&NWanI3pXLi6%73%xnp9^Q**VuN*DHi7>^S*hu2ErqDE{V|k467HlRZ?E z>diA-{l8PWd9?1O62CK>CI35`7X6D%o=`QBxB3SNxA?y%{$Q@i zy!e6QkNdgUd?4i)lb$l9e~Gyv`Ag{hh0jlyP`h`COU(j__oX!6{Ji#3lP#1$)5c2}a^l{7Bg{z|mJ6746TTmsrpFyBi131~mT%#`+5 zVgFx+@~hA;r}MtQ3hl2(`%G?sHQHZ|_E)3*)o4Ev1?L?w$?m$FVHw9AwS79az3Panq+iF!L>&rUPX4O0JpLH~Y1`Y-6$FUa48db^On z%X}~W*oA)Vg5A4N{#S4}>g`6o-6m_;42s9yri53Y?B0!Xd(i$Kl-q-Hdtk>Nl;4B$ zd!ctP%I`G`#GbvdXD{mSgpFJ^N9AKk6Sq`2%SG z0QTL3=*K~%AB6n}k$(vF4k7=LStxcNg58H;?;(^w3_B0Q&cmqB>1-#bvzuJEB=EPSMw z2$P)vtvko*c;8RQCnwBfy`A)*pmmDZ&lA*sCE5p1nA55*l|NzrQk!U<{0;X1M&%aM zdi5I}cP$~FMEXe_cm59fce?(WMEd`L{D-mM+x!#qpEw>m1^E=@(`J5PijUsYG=;*x z73TH67utREwE0c?f7<*GKKEB8dr#9kQBU~XA1OSonh3kcMj zucxr9{zRB1Fp>On+MH9>s9&eeuuyejz?mw1>7OQy(T%8Ir`g}M&i`cwi5-8LM0YNY z$KPh4$bUmVV+Mz}KW3+Q zLHLb+M)seh@_hWjkIOk47t1U%J}mP%ouhGEihN7@e;8!ud9%d5 zK>9Dx@#aXfhZ#ogUNB4DvXZ{kO(9++JamFk~@ygsp<8_J7JM&O{Trx{^W8orqBE`og zvsmJY(!meCe`p*oQ9OCHj))#w|1Ozd0%5d&F#|$-|B>_0Qa}EWx%YstqDmXM&)k-q z2A5C_q2wk62`U(x25S&hENFzFAgCdrf+Zx@*Z~W%V_UJ~+Hh^y(Y2s!Vbxt(b$4-B zS42UIDE6qR-~XBOOgN7BqVN0e_x--JE9cJq=XuU^$~>pcnaN6GY3CA4`V!0gXDhkS zWgTKA^Pob~TN%GQvCfaTPqv_UCG{@*R9EtTODlRmh@b3#m*xYVM}0v3$$aVqeLnL+ z+_skw7>{+wPKnF?*glu}Aa4JzyU2;&57jP-#jX$a{`fF{YvK~@kYl+|R_S#T%XJcq z{j1deRcik#%I_ur`c=6DXdkN>_enlhX+BnIK31!~)tnc7t2JM%HD9YWU#m4=t2JMI zJ<`vY#FDSoj5Fl<`)bXX$VtBBdhur;sl6YeSNwp);s-v`d`c|&Tch@@QG3?J_x0^s z!~FR{u3v*bc}~41Zs*Hu;x+l(pnt9EUyGdhhqdZQ*2b+LS&P52=dB-E8@GOBZQS~i zwQ=i5*2b+LSsS;0MD&S&5Pdebenj-yW9vsmpFOsIMD*EX>qkVNJ+|#a^ooBFy*9Rf zMD*HY>qkVdJ+^*C^x9+VM?|kZwti$SY`s}gwC!$Y|*J-`4i{F)allj%U_^HXE+z;#GGn0GzaZ~aPKOU2uL_MyHHz#lP z;}eoc(w^4E??}kLoptd$HNHIh0)Bp7{KVwF)XzHBOOup`#GU;3q~ssy*Ve^PPTq>& zSr^|wIT3%bE`Ca~EB*I+ZNC!B{j^@&BVW<>+uwR^kL$HPt|!0JF6BCDA99_IZ99|e z?6GZU>-GMV>!sf1dK=sJBiGww+kVzl&(a+G?uzmGNlAFIFKpvMx6zuKVwYJ>W#4eFmZpkMsP2K7&$sN5%- z?@w4SlfUcysh5xXS*EWRMI0{$6#-uL5$i3_Rc&++qX zIsO7a`zi64@l$hU{qHNi{wuxyYqk4pwfk$e`)jrPYqk4pwfh^@_YLwJ(fb|a&)$9b zdrfNJCa#zEu}S^vCiS12xL*9_CcXX#z5WNTllL8dh}Y-s;K!rPMcDs?`dhi~K*|N4 zFW&|D0e$k`><{tl%>n3>_&`6t(SM)+N7egdd{J^C?e0h3tNDn0{}eyVA6J@$?I%s* z8a<|7dH=~IR`~l@O=6#-_VhObolC32RrBbR>V#gxxp5SD14v$WJseC%3O3 z-{j{jnm9MN7v&sF*!|P7gxzNnOW6J?mayd;Ph9KUlfVwKFOj$}A?uVmiS_<*QstA_ za|-%%H6OW&S-Cr7UvA>w#N}L{mw4O1K2P=L>vj2w3;caB1$tbd$Ax-a*nWILZg2Dz zChWMqFk#30g`_`=`Y6P{N2tdRD%T-#p0Br~9(Qa%wsIX4RmqF-ODXC>@|Dv1OOgLq z@pGxf{gKx+{)@(c_2V(-b&Z$#@!-hc{CJdkL*ut-$0_nD<6wykHSVDClYTtP$ooB= z)V@xdpCXQB+*PFZ6sg^v^}5cKhuGU$%b~N%b=LfJ)^g~g*LO*r=6`3nOX6Wa?wUB$ zm+zW5KUdmGw}gFPwVUeeruw=eFY}^qTE5*9j~IKsE$42On|$YDE7e!5c1tYw6{{XO z7CpskUoqt?dWyAu7AO9YIFWWE@u_}%PojmmyXxt#`R$&V>)YL3?d-1lyC>E~_M$wy ztN!kq{}PoiQTY;;D^WS&VpoaguSDf~s9cZ44LKgXN8*{ho3OV>;@Q00{P^gYKM?mywEFh=>C(I(+>ixyX;#kJ`~EajkDhh1yY}c2p#K`gT;P9TjRvMdDH4jtWh;BJqM>E)|+? zMWSy~%B64O?|%7iorpwV!tQ=*M`hv-UvFh1?cL4&S(#X$`%ga}W4`s{Zr*o(Jj@KG z+$$4f%rK32@Z&z-E`GeV#ueVK{&8Q8hxyNcDih`2@&5U0Bi~1_OnjMllOK;UxA<|| zd&Q5-ynNbgW#R*GoF9+NztfLbdg2!<6CY~aC;yN3d_ta|R3?@uKKA3Z_bqY%M4q3o z{)x~0{Pa&8;OD1*!se%cVh2Aza$fQyvE-+J!sbV~Jh$qvcK1&V_wAPJUrE&(*k9<4ZJ7(sg&@zi&ML zA*|$HT9}tIyKm^=ANS{9L4Exr+F=*_<1*#jC=cZOE5A+ow(w3VtAGF1d3*=T%0H!i zJBQaf{59pqz7hVN@; z&&7>tEl8RE8?Aju!`=Ejf@>GRcTnCGl5fy-j#qwaNPaea8Sb@v$WK&$0X)z@nfCh= zyba!<{34Mb#d@jQ_ftM&sE#Q!Px<8{U$dEi!M#!me^L27k*Caq`WIdU|62L)Lj1EL z-VK1KzO?z9FWf1=SZDTx!n-TKE+ij^FNGhZ{CeSA+w`ZAzvZ;Q74Soq-w@&($p1$8 z(aLWO@yQfcGL=*Z<+nNfOy%|PysvEe-wt=v7yV7}YUNLa z`0LcwQut%ap9=9gv;_$p;0MsYpXRr#|6|Hu!X}k}hTm@ee-BUN(5k++_B{(1`LDEn zrgM#{hL2Ld1SUbs_dN16|h@DgU!@ z$~0&{XTa;>)7q^5S5!XWPl`O@c;&D1+b#cR;VsBtsr)s$m~(;rm;9a&55nv6L-Q{` zg)f9Zr1Hx+C-%GYi{Wo6|CCYg9hSUku&`FHycBB!3vXl3nRPm2V93v*5+>y_A0` z+^N4dcqRN`<==+nzrjD(!h=vE`Qk9BzZ}Z19)7yYmutR--%I@oZ-QT>yn^#?{Rp20 zzgu}6I{YmKXfqj;XA`S)okv+Bebs-#VPYPT*5An{-@BNkoE^J|IzYY zMV{-PB$n{^jyC=demDAelk;21|2`@o*f$mXmUd5>t$wol4-E06B!BQq*8;j`d>Qhr1R`9<(G@I~ma7fu5Zd3XLY z&a?WbINY7T9I3p);ja7{%BMQqmA_Q^G>5zLw<$l;;ja9n%8zomEB~tUqaE(bzpH$@ z!(I6=l+SRuD<6;8@;L@BAy3=K5X!%e^6jDgSdoYSN3Ro}?2$4<;Ez-O$7SHf@O_jw ziJaqqM7|O}LG?GQd{F1FMvz^1m$-WzCuFxgtc$3$aB26{6jbZc?o+cZ&e=1--7(; z-YGLl`Gs&-|4#&w|CjQMMBedFJCMJ6`RrV%nd4kWd@#xFI0Y223`e!PWjatcn$m&<<|(`+SZTE&;G;u zO)WeKzYFn^)Q`)r4e_B;zK;BL!ci97pM&8N-q!T57w**0IL2>H$bX^yh75cbJeIWe zb5jOB4_>JJR)@bu{zU%*csJ#@Ib7@#z6f5b{O$~V3A|GI9~}OO>R$#Q2LF`$eHXtK z_=k7kfqytm<^SVw*FT=9dxE^C=0(|`I;>J1?6kA@P8>^mxXUs zzTV+({Uq{i{y%oOYkyDW8?x}h%0Gd-_4k9^UzG1?_~*2bPeXh!^?zN9Z2B8Rd^CJ{ z8Q;K$7rfBXd=}!jhQ8tj{13=W_!2Hx z3;$SoHT*sJ5cGc)O8+)VpD?})V^8?k@Sy#>?ej9Ygl`<~mjCU_+Z_GUf8CD$TGD?K zJ`(-kW|7b9YW08T$V+;6s(n3`fA7e<^;@faQ%L?UJiin974V0ZcZ9qCL3kVd9r&TNuT+Tt11{mP z?l%8LqF?WC_x?Gm#PZG#cgyb_j+J7(o=k$t{c^ck;{l$)a z?4!;7-(EeeeLWoRwx7w$OC4U|$e*A*?QpmKv??!ic%4)KH!Cl9co#?iBg*?Y+|B&`3`XR{*(3Xja#S85xs2r?kHUEAGiK;ds|-ZaJT+Sl@E8gD?d#6P7ZhF_fx(z zTtZ;~H}ZU^ANg0l3*610@GAKE@J8;h-INz-KXkA-E5c9k6X7*k(jQT3^S4J9K2G^a zhr9VZQu!!NU+kCu`E1f}#lBPFr;+}i4i~$G&xcn5gSl zt?&!5e}A}yXOtfbZ|R>h_p1DukbdC{;IAq_03NhI*FSxw{6L3SIQ}80kF~GX;coqO zSALMgzjXZH_R7b?gZ8x@`epro8TO4(KF;BiPvPlpQsx5Xbs2ao{B`Arg!om+FM_XA zJ^>!&-?hK7!sc(H!`=R2mh#CV{nv~A=)Vp=kNP>xk$21Yx&BuF;ljyRaR1%L!IEuL z=1us`$jjcKz&=-g$8D{AeJK6AkY9oPKJbbc=>r{kH-GnSXXU3l-1QI7Dxa2xFIRqK z7QRmTQ4V+0Hv???(;e=n-%a_9EPOlV$2i>8zlZW;;qLt71D;=0Z>KjIs1fe=Z^B2z z>r}oe1D^mtOnI|#r~X8KI{YZ*C%`k+-?js7{!a|a|HS%#v!WUA1Cy9K`=JDHe zm|iuI_O-p0KRE**4S!wvDIxtLUk@*=vht_Gx3oUrBJ$4q$QZ_dO~~)B@@Gi;=#=*R znsa}TSAMz3Z*BQB_yopht;h#qUKaUAmA^vdxmfgXLVhCh^O2tmUq$&`DcrH|Q1}}7 zGL^qNl>VYn`qwz=yY1&~mA^J5e=}*h_Roh)Zi4&wA-II~Dt`l9Lcl-Mtmh1}<$qI_ z^oy0>oP}?r{1%71{%IHGx53@=|AO}E+JBF5>QwBzn)3Y;ZnjUE1CU=&`Q96n|0*PZ zAAC#g=YDw5KHU5JuS0D9AIQQ#Qobk)|3UeKS$OB6R{uj;cz@*&XW_dme+2IOkLM_> zYRV@FkHUlYDd&WbhEGuW$1?Cm@EessF6kpDGLDPUAB3klcK7!PuY{MY{IkNfee8mLf8)3Yeu(ntIhG*l z%lxI9Yg^#wD}O<_lm71Tc~1H-iGGzIg}m?u@S9YAsc`51z7M_#zFPU8RX!LW-4B-# z-JzrLUg8-+Nd5)*Qsn!=CA_Ng?}-tF7gfFro{#)%A^Df!E07SK9oy&yXChS|J4K!!W+T~fxPfp@b^^yP2uQt z`8<)|(dPf3q4ZCttQWw`l)nw{r1j^{PX{aim&0BExu5cXXW>(nzvFOM|EbF5c|ef9 z+rP|H{+`3#@#US$S2%os%0IY2o>l%n+%4Zt6w6{M9}O$vLHm?b!k5DTrt+&C{qFie z$7)-?t3&di;6GD?Q>HJxl=iX4k$1~)iOR1Pd2RpFe#SCBs7C$`|Mu0d@UI>Hb(GH& zwExr)_IIlOZyYZDoA65bWAF;fzfJTLx3_Z@2dPek>7%U z{$Aw+|MaB#hdSg3lKzkIpnbdb->mw7a=6=no~vB$?M(95DHq=za992w<&iA>Ipr~U z(EcR<&yfF(#*u~DjJHWS8fATl9-2Cs&@kaF5A}>MywpLKSZvIbK-dW_iF7Us9 z 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.>hWq`ZaDvSeFmexNY`vyW85& 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

ZAwE>`nizns@o=dCNTz&pE=* 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ß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 ?%x?el$T)R!Ri_u8dt&eQH>8XBP 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

## Contributors +Thanks to everyone involved including the [third-party libraries](https://github.com/rhunk/SnapEnhance/tree/refactor_2_0_0?tab=readme-ov-file#privacy) used! - [rathmerdominik](https://github.com/rathmerdominik) - [Flole998](https://github.com/Flole998) - [authorisation](https://github.com/authorisation/) @@ -167,14 +170,6 @@ We do not collect any user information. However, please be aware that third-part - [xerta555](https://github.com/xerta555) - [TheVisual](https://github.com/TheVisual) -## Credits -- [LSPosed](https://github.com/LSPosed/LSPosed) -- [dexlib2](https://android.googlesource.com/platform/external/smali/+/refs/heads/main/dexlib2/) -- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) -- [osmdroid](https://github.com/osmdroid/osmdroid) -- [coil](https://github.com/coil-kt/coil) -- [Dobby](https://github.com/jmpews/Dobby) - ## Donate - LTC: LbBnT9GxgnFhwy891EdDKqGmpn7XtduBdE From 2740519ec8d212dce4b29f0fe594f51259b37dc5 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 00:29:46 +0100 Subject: [PATCH 188/274] build: compose bom --- app/build.gradle.kts | 2 +- .../rhunk/snapenhance/ui/manager/Navigation.kt | 4 ++-- .../sections/features/FeaturesSection.kt | 4 ++-- .../ui/manager/sections/home/HomeSection.kt | 4 ++-- .../ui/manager/sections/home/SettingsSection.kt | 4 ++-- .../rhunk/snapenhance/ui/setup/SetupActivity.kt | 4 ++-- gradle/libs.versions.toml | 17 ++++++++--------- manager/build.gradle.kts | 8 +++++++- .../snapenhance/manager/ui/tab/impl/HomeTab.kt | 4 ++-- 9 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9960f39b..345baf148 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,6 +134,7 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.rhino) implementation(libs.androidx.activity.ktx) + fullImplementation(platform(libs.androidx.compose.bom)) fullImplementation(libs.bcprov.jdk18on) fullImplementation(libs.androidx.navigation.compose) fullImplementation(libs.androidx.material.icons.core) @@ -151,7 +152,6 @@ dependencies { afterEvaluate { properties["debug_assemble_task"]?.let { tasks.findByName(it.toString()) }?.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") } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt index f447e6aca..d547565f3 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt @@ -6,7 +6,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -80,7 +80,7 @@ class Navigation( IconButton( onClick = { navHostController.popBackStack() } ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + Icon(Icons.Filled.ArrowBack, contentDescription = null) } } }, actions = { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt index 9f2fdb7e8..2de304916 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.MoreVert @@ -245,7 +245,7 @@ class FeaturesSection : Section() { } } else { IconButton(onClick = it) { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + Icon(Icons.Filled.OpenInNew, contentDescription = null) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 3ce41c39c..5b934b900 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -156,7 +156,7 @@ class HomeSection : Section() { Button(onClick = { context.checkForRequirements(Requirements.LANGUAGE) }, modifier = Modifier.height(40.dp)) { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) + Icon(Icons.Filled.OpenInNew, contentDescription = null) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt index e5df1223f..f748125f1 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -72,7 +72,7 @@ class SettingsSection : Section() { Text(text = title, modifier = Modifier.padding(start = 26.dp)) IconButton(onClick = { takeAction() }) { Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, + imageVector = Icons.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(24.dp) ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt index 7bcc158ed..f7f03c422 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/SetupActivity.kt @@ -9,7 +9,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.ArrowForwardIos import androidx.compose.material.icons.filled.Check import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon @@ -122,7 +122,7 @@ class SetupActivity : ComponentActivity() { imageVector = if (requiredScreens.size <= 1 && canGoNext) { Icons.Default.Check } else { - Icons.AutoMirrored.Default.ArrowForwardIos + Icons.Default.ArrowForwardIos }, contentDescription = null ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2614b4700..33eb065fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,13 +9,12 @@ kotlinx-coroutines-android = "1.7.3" activity-ktx = "1.8.0" androidx-documentfile = "1.1.0-alpha01" -material-icons-core = "1.5.3" -material-icons-extended = "1.6.0-alpha07" coil-compose = "2.4.0" navigation-compose = "2.7.4" osmdroid-android = "6.1.17" -recyclerview = "1.3.1" +recyclerview = "1.3.2" +compose-bom = "2023.10.01" bcprov-jdk18on = "1.76" dexlib2 = "2.5.2" ffmpeg-kit = "5.1.LTS" # DO NOT UPDATE FFMPEG-KIT TO "5.1" it breaks stuff :3 @@ -24,19 +23,19 @@ junit = "4.13.2" material3 = "1.1.2" okhttp = "5.0.0-alpha.11" rhino = "1.7.14" -ui-tooling-preview = "1.5.3" [libraries] +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" } androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } -androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "material-icons-core" } -androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } -androidx-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "material-icons-core" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-material-ripple = { module = "androidx.compose.material:material-ripple" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } -androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-tooling-preview" } -androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "ui-tooling-preview" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index bfb814b2e..d8207de68 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -42,7 +42,12 @@ android { proguardFiles += file("proguard-rules.pro") } debug { - isMinifyEnabled = false + (properties["debug_assemble_task"] == null).also { + isDebuggable = !it + isMinifyEnabled = it + isShrinkResources = it + } + proguardFiles += file("proguard-rules.pro") } } @@ -78,6 +83,7 @@ dependencies { implementation(libs.gson) implementation(libs.jsoup) implementation(libs.okhttp) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material3) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.navigation.compose) diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt index 351b4c4fb..7d69f149f 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Home @@ -80,7 +80,7 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { Text(text = "Not installed", fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface) } - Icon(imageVector = Icons.AutoMirrored.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) + Icon(imageVector = Icons.Default.OpenInNew, contentDescription = null, Modifier.padding(10.dp)) } } } From c3f04f594275969db4ccf62205586d2e4541f5de Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 01:08:25 +0100 Subject: [PATCH 189/274] build: debug properties --- app/build.gradle.kts | 13 ++++++------- build.gradle.kts | 2 +- manager/build.gradle.kts | 2 +- native/build.gradle.kts | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 345baf148..b5f316c97 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import org.gradle.configurationcache.extensions.capitalized plugins { alias(libs.plugins.androidApplication) @@ -33,7 +34,7 @@ android { proguardFiles += file("proguard-rules.pro") } debug { - (properties["debug_assemble_task"] == null).also { + (properties["debug_flavor"] == null).also { isDebuggable = !it isMinifyEnabled = it isShrinkResources = it @@ -150,16 +151,14 @@ dependencies { } afterEvaluate { - properties["debug_assemble_task"]?.let { tasks.findByName(it.toString()) }?.doLast { + properties["debug_flavor"]?.toString()?.let { tasks.findByName("install${it.capitalized()}Debug") }?.doLast { runCatching { exec { - commandLine("adb", "shell", "am", "force-stop", "com.snapchat.android") + commandLine("adb", "shell", "am", "force-stop", properties["debug_package_name"]) } + Thread.sleep(1000L) exec { - commandLine("adb", "install", "-r", "-d", apkDebugFile.absolutePath) - } - exec { - commandLine("adb", "shell", "am", "start", "com.snapchat.android") + commandLine("adb", "shell", "am", "start", properties["debug_package_name"]) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 6aa3c8591..f5987e9b6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ var versionCode = 1 //"1" for now until stable release rootProject.ext.set("appVersionName", versionName) rootProject.ext.set("appVersionCode", versionCode) rootProject.ext.set("applicationId", "me.rhunk.snapenhance") -rootProject.ext.set("buildHash", properties["custom_build_hash"] ?: java.security.SecureRandom().nextLong(1000000000, 99999999999).toString(16)) +rootProject.ext.set("buildHash", properties["debug_build_hash"] ?: java.security.SecureRandom().nextLong(1000000000, 99999999999).toString(16)) tasks.register("getVersion") { doLast { diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index d8207de68..60ee6a7f0 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -42,7 +42,7 @@ android { proguardFiles += file("proguard-rules.pro") } debug { - (properties["debug_assemble_task"] == null).also { + (properties["debug_flavor"] == null).also { isDebuggable = !it isMinifyEnabled = it isShrinkResources = it diff --git a/native/build.gradle.kts b/native/build.gradle.kts index 9f0acdbcb..8ac64c172 100644 --- a/native/build.gradle.kts +++ b/native/build.gradle.kts @@ -29,7 +29,7 @@ android { } ndk { //noinspection ChromeOsAbiSupport - abiFilters += properties["custom_abi_filters"]?.toString()?.split(",") + abiFilters += properties["debug_abi_filters"]?.toString()?.split(",") ?: listOf("arm64-v8a", "armeabi-v7a") } } From 4759d910b3d1851960c28882ad02da967e24e8de Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 01:36:40 +0100 Subject: [PATCH 190/274] fix(manager/install_tab): detect package uninstall --- .../manager/ui/tab/impl/download/InstallPackageTab.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt index bd7578c85..76334f8e3 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt @@ -99,9 +99,13 @@ class InstallPackageTab : Tab("install_app") { installPackageCallback = null } - val downloadPath = getArguments()?.getString("downloadPath") ?: return - val appPackage = getArguments()?.getString("appPackage") ?: return - val shouldUninstall = getArguments()?.getBoolean("uninstall") ?: false + val downloadPath = remember { getArguments()?.getString("downloadPath") } ?: return + val appPackage = remember { getArguments()?.getString("appPackage") } ?: return + val shouldUninstall = remember { getArguments()?.getBoolean("uninstall")?.let { + if (runCatching { activity.packageManager.getPackageInfo(appPackage, 0) }.getOrNull() == null) { + false + } else it + } ?: false } Column( modifier = Modifier.fillMaxSize().padding(16.dp), From eb803df196293d07ac4477fca6c2b0af080dd305 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 02:28:13 +0100 Subject: [PATCH 191/274] fix(core): hide streak restore --- .../snapenhance/core/features/impl/ui/HideStreakRestore.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt index 4f3a835df..ae1be918b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/HideStreakRestore.kt @@ -4,16 +4,14 @@ import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hookConstructor -import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField class HideStreakRestore : Feature("HideStreakRestore", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { override fun onActivityCreate() { if (!context.config.userInterface.hideStreakRestore.get()) return - context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> - val streakMetadata = param.thisObject().getObjectField("mStreakMetadata") ?: return@hookConstructor - streakMetadata.setObjectField("mExpiredStreak", null) + findClass("com.snapchat.client.messaging.ExpiredStreakMetadata").hookConstructor(HookStage.AFTER) { param -> + param.thisObject().setObjectField("mIsRestorable", false) } } } \ No newline at end of file From 94d58c4f46175f422becd0f740a327eec1453cac Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 16:45:31 +0100 Subject: [PATCH 192/274] feat: in-chat snap preview --- common/src/main/assets/lang/en_US.json | 4 + .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../core/features/impl/ui/SnapPreview.kt | 88 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 1 + .../core/util/media/PreviewUtils.kt | 23 +++-- .../snapenhance/mapper/impl/CallbackMapper.kt | 4 + 6 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 19b0c71d4..43b9b7c9c 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -232,6 +232,10 @@ } } }, + "snap_preview": { + "name": "Snap Preview", + "description": "Displays a small preview next to unseen Snaps in chat" + }, "bootstrap_override": { "name": "Bootstrap Override", "description": "Overrides user interface bootstrap settings", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index be7319604..fb9c9612f 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -26,6 +26,7 @@ class UserInterfaceTweaks : ConfigContainer() { val friendFeedMenuPosition = integer("friend_feed_menu_position", defaultValue = 1) val amoledDarkMode = boolean("amoled_dark_mode") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() } + val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt new file mode 100644 index 000000000..cd29557ab --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt @@ -0,0 +1,88 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.graphics.* +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import java.io.File + +class SnapPreview : Feature("SnapPreview", loadParams = FeatureLoadParams.INIT_SYNC or FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val mediaFileCache = mutableMapOf() // mMediaId => mediaFile + private val bitmapCache = EvictingMap(50) // filePath => bitmap + + private val isEnabled get() = context.config.userInterface.snapPreview.get() + + override fun init() { + if (!isEnabled) return + context.mappings.getMappedClass("callbacks", "ContentCallback").hook("handleContentResult", HookStage.BEFORE) { param -> + val contentResult = param.arg(0) + val classMethods = contentResult::class.java.methods + + val contentKey = classMethods.find { it.name == "getContentKey" }?.invoke(contentResult) ?: return@hook + if (contentKey.getObjectField("mMediaContextType").toString() != "CHAT") return@hook + + val filePath = classMethods.find { it.name == "getFilePath" }?.invoke(contentResult) ?: return@hook + val mediaId = contentKey.getObjectField("mMediaId").toString() + + mediaFileCache[mediaId.substringAfter("-")] = File(filePath.toString()) + } + } + + @SuppressLint("DiscouragedApi") + override fun onActivityCreate() { + if (!isEnabled) return + val chatMediaCardHeight = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_height", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + val chatMediaCardSnapMargin = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + val chatMediaCardSnapMarginStartSdl = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin_start_sdl", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + + fun decodeMedia(file: File) = runCatching { + bitmapCache.getOrPut(file.absolutePath) { + PreviewUtils.resizeBitmap( + PreviewUtils.createPreviewFromFile(file) ?: return@runCatching null, + chatMediaCardHeight - chatMediaCardSnapMargin, + chatMediaCardHeight - chatMediaCardSnapMargin + ) + } + }.getOrNull() + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, messageId -> + event.view.removeForegroundDrawable("snapPreview") + + val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage + val messageReader = ProtoReader(message.messageContent ?: return@chatMessage) + val contentType = ContentType.fromMessageContainer(messageReader.followPath(4, 4)) + + if (contentType != ContentType.SNAP) return@chatMessage + + val mediaIdKey = messageReader.getString(4, 5, 1, 3, 2, 2) ?: return@chatMessage + + event.view.addForegroundDrawable("snapPreview", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + if (canvas.height / context.resources.displayMetrics.density > 90) return + val bitmap = mediaFileCache[mediaIdKey]?.let { decodeMedia(it) } ?: return + + canvas.drawBitmap(bitmap, + canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(), + (canvas.height - bitmap.height) / 2f, + null + ) + } + })) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 789e7e291..7b84335b6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -102,6 +102,7 @@ class FeatureManager( HideFriendFeedEntry::class, HideQuickAddFriendFeed::class, CallStartConfirmation::class, + SnapPreview::class, ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt index 2d384c853..8dbe46aa4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/PreviewUtils.kt @@ -8,6 +8,7 @@ import android.media.MediaDataSource import android.media.MediaMetadataRetriever import me.rhunk.snapenhance.common.data.FileType import java.io.File +import kotlin.math.max object PreviewUtils { fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { @@ -52,14 +53,20 @@ object PreviewUtils { } } - private fun resizeBitmap(bitmap: Bitmap, outWidth: Int, outHeight: Int): Bitmap? { - val scaleWidth = outWidth.toFloat() / bitmap.width - val scaleHeight = outHeight.toFloat() / bitmap.height - val matrix = Matrix() - matrix.postScale(scaleWidth, scaleHeight) - val resizedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) - bitmap.recycle() - return resizedBitmap + fun resizeBitmap(source: Bitmap, outWidth: Int, outHeight: Int): Bitmap { + val sourceWidth = source.getWidth() + val sourceHeight = source.getHeight() + val scale = max(outWidth.toFloat() / sourceWidth, outHeight.toFloat() / sourceHeight) + + val dx = (outWidth - (scale * sourceWidth)) / 2F + val dy = (outHeight - (scale * sourceHeight)) / 2F + val dest = Bitmap.createBitmap(outWidth, outHeight, source.getConfig()) + val canvas = Canvas(dest) + canvas.drawBitmap(source, Matrix().apply { + postScale(scale, scale) + postTranslate(dx, dy) + }, null) + return dest } fun mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt index a7b132e64..0176fb1a0 100644 --- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt @@ -17,6 +17,10 @@ class CallbackMapper : AbstractClassMapper() { if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false + // ignore dummy ContentCallback class + if (superclassName.endsWith("ContentCallback") && !clazz.methods.first { it.name == "" }.parameterTypes.contains("Z")) + return@filter false + val superClass = getClass(clazz.superclass) ?: return@filter false !superClass.isFinal() }.map { From 3d053155c35c27146f64880b23f5fd6d9f564b63 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 1 Nov 2023 16:55:01 +0100 Subject: [PATCH 193/274] ci(beta): upload manager --- .github/workflows/beta.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 52c22674d..fa5aae0dc 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -49,13 +49,19 @@ jobs: mv app/build/outputs/apk/armv8/debug/*.apk app/build/outputs/apk/armv8/debug/snapenhance-${{ env.version }}-armv8-${{ steps.version-env.outputs.sha_short }}.apk mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/snapenhance-${{ env.version }}-armv7-${{ steps.version-env.outputs.sha_short }}.apk mv app/build/outputs/apk/all/debug/*.apk app/build/outputs/apk/all/debug/snapenhance-${{ env.version }}-universal-${{ steps.version-env.outputs.sha_short }}.apk - + + - name: Upload manager + uses: actions/upload-artifact@v3.1.2 + with: + name: manager + path: manager/build/outputs/apk/debug/*.apk + - name: Upload core uses: actions/upload-artifact@v3.1.2 with: name: core path: app/build/outputs/apk/core/debug/*.apk - + - name: Upload armv8 uses: actions/upload-artifact@v3.1.2 with: From 0ea7001ec50a3731e1ee1ef8915d84e5499abe47 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:25:41 +0100 Subject: [PATCH 194/274] refactor: resources ktx --- .../me/rhunk/snapenhance/common/Constants.kt | 2 +- .../impl/experiments/AmoledDarkMode.kt | 4 +- .../impl/messaging/CallStartConfirmation.kt | 5 +- .../features/impl/messaging/SendOverride.kt | 6 +- .../impl/ui/FriendFeedMessagePreview.kt | 20 +++---- .../core/features/impl/ui/SnapPreview.kt | 12 ++-- .../core/features/impl/ui/UITweaks.kt | 38 ++++++------ .../core/messaging/MessageExporter.kt | 25 ++++---- .../core/ui/ViewAppearanceHelper.kt | 38 ++++++++---- .../snapenhance/core/ui/menu/AbstractMenu.kt | 4 ++ .../core/ui/menu/impl/ChatActionMenu.kt | 60 ++++--------------- .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 25 ++++---- .../core/ui/menu/impl/MenuViewInjector.kt | 47 +++++++-------- .../ui/menu/impl/OperaContextActionMenu.kt | 26 ++++---- .../core/ui/menu/impl/SettingsGearInjector.kt | 37 ++++-------- .../core/ui/menu/impl/SettingsMenu.kt | 23 ------- .../snapenhance/core/util/ktx/AndroidExt.kt | 33 ++++++++++ .../core/util/ktx/XposedHelperExt.kt | 2 +- 18 files changed, 196 insertions(+), 211 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt index 2613f2749..2025fe7ac 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.common object Constants { - const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" + val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android" val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AmoledDarkMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AmoledDarkMode.kt index f28fa98a2..7d117faf2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AmoledDarkMode.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AmoledDarkMode.kt @@ -3,12 +3,12 @@ package me.rhunk.snapenhance.core.features.impl.experiments import android.annotation.SuppressLint import android.content.res.TypedArray import android.graphics.drawable.ColorDrawable -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getIdentifier class AmoledDarkMode : Feature("Amoled Dark Mode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { @SuppressLint("DiscouragedApi") @@ -18,7 +18,7 @@ class AmoledDarkMode : Feature("Amoled Dark Mode", loadParams = FeatureLoadParam fun getAttribute(name: String): Int { if (attributeCache.containsKey(name)) return attributeCache[name]!! - return context.resources.getIdentifier(name, "attr", Constants.SNAPCHAT_PACKAGE_NAME).also { attributeCache[name] = it } + return context.resources.getIdentifier(name, "attr").also { attributeCache[name] = it } } context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt index 38b84b458..6fa39498e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt @@ -9,6 +9,7 @@ import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.util.hook.HookAdapter import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId class CallStartConfirmation : Feature("CallStartConfirmation", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) { @@ -33,8 +34,8 @@ class CallStartConfirmation : Feature("CallStartConfirmation", loadParams = Feat } } - val callButton1 = context.resources.getIdentifier("friend_action_button3", "id", "com.snapchat.android") - val callButton2 = context.resources.getIdentifier("friend_action_button4", "id", "com.snapchat.android") + val callButton1 = context.resources.getId("friend_action_button3") + val callButton2 = context.resources.getId("friend_action_button4") findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param -> val view = param.thisObject() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt index 52d7a4538..bd2ea3d8c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.features.impl.messaging -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.common.util.protobuf.ProtoReader @@ -14,6 +13,7 @@ import me.rhunk.snapenhance.nativelib.NativeLib class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { private var isLastSnapSavable = false + private val arroyoMessageContainerPath = intArrayOf(4, 4) private val typeNames by lazy { mutableListOf( "ORIGINAL", @@ -33,8 +33,8 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe val protoEditor = ProtoEditor(event.buffer) - if (isLastSnapSavable && ProtoReader(event.buffer).containsPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11)) { - protoEditor.edit(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11, 5, 2) { + if (isLastSnapSavable && ProtoReader(event.buffer).containsPath(*arroyoMessageContainerPath, 11)) { + protoEditor.edit(*arroyoMessageContainerPath, 11, 5, 2) { remove(8) addBuffer(6, byteArrayOf()) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt index 383cc36b3..9671a4db0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -9,7 +9,6 @@ import android.graphics.drawable.shapes.Shape import android.text.TextPaint import android.view.View import android.view.ViewGroup -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent @@ -19,20 +18,21 @@ import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption import me.rhunk.snapenhance.core.ui.addForegroundDrawable import me.rhunk.snapenhance.core.ui.removeForegroundDrawable import me.rhunk.snapenhance.core.util.EvictingMap +import me.rhunk.snapenhance.core.util.ktx.getDimens +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getIdentifier import kotlin.math.absoluteValue @SuppressLint("DiscouragedApi") class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { private val sigColorTextPrimary by lazy { context.mainActivity!!.theme.obtainStyledAttributes( - intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) ).getColor(0, 0) } private val friendNameCache = EvictingMap(100) - private fun getDimens(name: String) = context.resources.getDimensionPixelSize(context.resources.getIdentifier(name, "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) - override fun onActivityCreate() { val setting = context.config.userInterface.friendFeedMessagePreview if (setting.globalState != true) return @@ -40,13 +40,13 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams val hasE2EE = context.config.experimental.e2eEncryption.globalState == true val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } - val ffItemId = context.resources.getIdentifier("ff_item", "id", Constants.SNAPCHAT_PACKAGE_NAME) + val ffItemId = context.resources.getId("ff_item") - val secondaryTextSize = getDimens("ff_feed_cell_secondary_text_size").toFloat() - val ffSdlAvatarMargin = getDimens("ff_sdl_avatar_margin") - val ffSdlAvatarSize = getDimens("ff_sdl_avatar_size") - val ffSdlAvatarStartMargin = getDimens("ff_sdl_avatar_start_margin") - val ffSdlPrimaryTextStartMargin = getDimens("ff_sdl_primary_text_start_margin").toFloat() + val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat() + val ffSdlAvatarMargin = context.resources.getDimens("ff_sdl_avatar_margin") + val ffSdlAvatarSize = context.resources.getDimens("ff_sdl_avatar_size") + val ffSdlAvatarStartMargin = context.resources.getDimens("ff_sdl_avatar_start_margin") + val ffSdlPrimaryTextStartMargin = context.resources.getDimens("ff_sdl_primary_text_start_margin").toFloat() val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + ffSdlAvatarStartMargin val separatorHeight = (context.resources.displayMetrics.density * 2).toInt() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt index cd29557ab..86feee795 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt @@ -1,10 +1,11 @@ package me.rhunk.snapenhance.core.features.impl.ui import android.annotation.SuppressLint -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.Shape -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent @@ -15,6 +16,7 @@ import me.rhunk.snapenhance.core.ui.removeForegroundDrawable import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.media.PreviewUtils import java.io.File @@ -44,9 +46,9 @@ class SnapPreview : Feature("SnapPreview", loadParams = FeatureLoadParams.INIT_S @SuppressLint("DiscouragedApi") override fun onActivityCreate() { if (!isEnabled) return - val chatMediaCardHeight = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_height", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) - val chatMediaCardSnapMargin = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) - val chatMediaCardSnapMarginStartSdl = context.resources.getDimensionPixelSize(context.resources.getIdentifier("chat_media_card_snap_margin_start_sdl", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)) + val chatMediaCardHeight = context.resources.getDimens("chat_media_card_height") + val chatMediaCardSnapMargin = context.resources.getDimens("chat_media_card_snap_margin") + val chatMediaCardSnapMarginStartSdl = context.resources.getDimens("chat_media_card_snap_margin_start_sdl") fun decodeMedia(file: File) = runCatching { bitmapCache.getOrPut(file.absolutePath) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt index 48ffe7bcd..14aa3cdb0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt @@ -7,21 +7,21 @@ import android.text.SpannableString import android.view.View import android.view.ViewGroup.MarginLayoutParams import android.widget.FrameLayout -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getIdentifier class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { private val identifierCache = mutableMapOf() @SuppressLint("DiscouragedApi") - fun getIdentifier(name: String, defType: String): Int { + fun getId(name: String, defType: String): Int { return identifierCache.getOrPut("$name:$defType") { - context.resources.getIdentifier(name, defType, Constants.SNAPCHAT_PACKAGE_NAME) + context.resources.getIdentifier(name, defType) } } @@ -45,11 +45,11 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE val displayMetrics = context.resources.displayMetrics val deviceAspectRatio = displayMetrics.widthPixels.toFloat() / displayMetrics.heightPixels.toFloat() - val callButtonsStub = getIdentifier("call_buttons_stub", "id") - val callButton1 = getIdentifier("friend_action_button3", "id") - val callButton2 = getIdentifier("friend_action_button4", "id") + val callButtonsStub = getId("call_buttons_stub", "id") + val callButton1 = getId("friend_action_button3", "id") + val callButton2 = getId("friend_action_button4", "id") - val chatNoteRecordButton = getIdentifier("chat_note_record_button", "id") + val chatNoteRecordButton = getId("chat_note_record_button", "id") View::class.java.hook("setVisibility", HookStage.BEFORE) { methodParam -> val viewId = (methodParam.thisObject() as View).id @@ -64,8 +64,8 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE { isImmersiveCamera } ) { param -> val id = param.arg(0) - if (id == getIdentifier("capri_viewfinder_default_corner_radius", "dimen") || - id == getIdentifier("ngs_hova_nav_larger_camera_button_size", "dimen")) { + if (id == getId("capri_viewfinder_default_corner_radius", "dimen") || + id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")) { param.setResult(0) } } @@ -75,17 +75,17 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE val view = event.view if (hideStorySections.contains("hide_for_you")) { - if (viewId == getIdentifier("df_large_story", "id") || - viewId == getIdentifier("df_promoted_story", "id")) { + if (viewId == getId("df_large_story", "id") || + viewId == getId("df_promoted_story", "id")) { hideStorySection(event) return@subscribe } - if (viewId == getIdentifier("stories_load_progress_layout", "id")) { + if (viewId == getId("stories_load_progress_layout", "id")) { event.canceled = true } } - if (hideStorySections.contains("hide_friends") && viewId == getIdentifier("friend_card_frame", "id")) { + if (hideStorySections.contains("hide_friends") && viewId == getId("friend_card_frame", "id")) { hideStorySection(event) } @@ -103,24 +103,24 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE } } - if (hideStorySections.contains("hide_suggested") && (viewId == getIdentifier("df_small_story", "id")) + if (hideStorySections.contains("hide_suggested") && (viewId == getId("df_small_story", "id")) ) { hideStorySection(event) } - if (blockAds && viewId == getIdentifier("df_promoted_story", "id")) { + if (blockAds && viewId == getId("df_promoted_story", "id")) { hideStorySection(event) } if (isImmersiveCamera) { - if (view.id == getIdentifier("edits_container", "id")) { + if (view.id == getId("edits_container", "id")) { 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")) { + if (view.id == getId("full_screen_surface_view", "id")) { Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { it.setArg(1, 1) it.setArg(3, displayMetrics.heightPixels) @@ -130,8 +130,8 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE if ( (viewId == chatNoteRecordButton && hiddenElements.contains("hide_voice_record_button")) || - (viewId == getIdentifier("chat_input_bar_sticker", "id") && hiddenElements.contains("hide_stickers_button")) || - (viewId == getIdentifier("chat_input_bar_sharing_drawer_button", "id") && hiddenElements.contains("hide_live_location_share_button")) || + (viewId == getId("chat_input_bar_sticker", "id") && hiddenElements.contains("hide_stickers_button")) || + (viewId == getId("chat_input_bar_sharing_drawer_button", "id") && hiddenElements.contains("hide_live_location_share_button")) || (viewId == callButtonsStub && hiddenElements.contains("hide_chat_call_buttons")) ) { view.apply { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt index c7f78ad60..9cf5d378f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt @@ -22,7 +22,11 @@ import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID -import java.io.* +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream import java.text.SimpleDateFormat import java.util.Collections import java.util.Date @@ -96,7 +100,7 @@ class MessageExporter( writer.flush() } - suspend fun exportHtml(output: OutputStream) { + private suspend fun exportHtml(output: OutputStream) { val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } val mediaFiles = Collections.synchronizedMap(mutableMapOf>()) val threadPool = Executors.newFixedThreadPool(15) @@ -318,14 +322,15 @@ class MessageExporter( } suspend fun exportTo(exportFormat: ExportFormat) { - val output = FileOutputStream(outputFile) - - when (exportFormat) { - ExportFormat.HTML -> exportHtml(output) - ExportFormat.JSON -> exportJson(output) - ExportFormat.TEXT -> exportText(output) + withContext(Dispatchers.IO) { + FileOutputStream(outputFile).apply { + when (exportFormat) { + ExportFormat.HTML -> exportHtml(this) + ExportFormat.JSON -> exportJson(this) + ExportFormat.TEXT -> exportText(this) + } + close() + } } - - output.close() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt index c394877b2..4cdc2bf91 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -12,11 +12,14 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.StateListDrawable import android.graphics.drawable.shapes.Shape +import android.os.SystemClock import android.view.Gravity +import android.view.MotionEvent import android.view.View import android.widget.Switch import android.widget.TextView -import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.core.util.ktx.getDimens +import me.rhunk.snapenhance.core.util.ktx.getIdentifier import kotlin.random.Random fun View.applyTheme(componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { @@ -54,11 +57,28 @@ fun View.addForegroundDrawable(tag: String, drawable: Drawable) { updateForegroundDrawable() } +fun View.triggerCloseTouchEvent() { + arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach { + this.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + it, 0f, 0f, 0 + ) + ) + } +} + +fun View.iterateParent(predicate: (View) -> Boolean) { + var parent = this.parent as? View ?: return + while (true) { + if (predicate(parent)) return + parent = parent.parent as? View ?: return + } +} + object ViewAppearanceHelper { - @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", - "ClickableViewAccessibility" - ) private var sigColorTextPrimary: Int = 0 private var sigColorBackgroundSurface: Int = 0 @@ -81,16 +101,16 @@ object ViewAppearanceHelper { if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { with(component.context.theme) { sigColorTextPrimary = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr")) ).getColor(0, 0) sigColorBackgroundSurface = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr")) ).getColor(0, 0) } } - val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", Constants.SNAPCHAT_PACKAGE_NAME) + val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font") val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 with(component) { @@ -117,9 +137,7 @@ object ViewAppearanceHelper { } if (component is Switch) { - with(resources) { - component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() - } + component.switchMinWidth = resources.getDimens("v11_switch_min_width") component.trackTintList = ColorStateList( arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) ), intArrayOf( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt index fce99ca7e..36efc906c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt @@ -1,9 +1,13 @@ package me.rhunk.snapenhance.core.ui.menu +import android.view.View +import android.view.ViewGroup import me.rhunk.snapenhance.core.ModContext abstract class AbstractMenu { lateinit var context: ModContext + open fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) {} + open fun init() {} } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt index 4bfc5a55a..f9ddfa2a6 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -2,15 +2,12 @@ package me.rhunk.snapenhance.core.ui.menu.impl import android.annotation.SuppressLint import android.content.Context -import android.os.SystemClock -import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.Button import android.widget.LinearLayout import android.widget.TextView -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader @@ -20,6 +17,8 @@ import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.ViewTagState import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.getDimens import java.time.Instant @@ -27,35 +26,11 @@ import java.time.Instant class ChatActionMenu : AbstractMenu() { private val viewTagState = ViewTagState() - private val defaultGap by lazy { - context.androidContext.resources.getDimensionPixelSize( - context.androidContext.resources.getIdentifier( - "default_gap", - "dimen", - Constants.SNAPCHAT_PACKAGE_NAME - ) - ) - } + private val defaultGap by lazy { context.resources.getDimens("default_gap") } - private val chatActionMenuItemMargin by lazy { - context.androidContext.resources.getDimensionPixelSize( - context.androidContext.resources.getIdentifier( - "chat_action_menu_item_margin", - "dimen", - Constants.SNAPCHAT_PACKAGE_NAME - ) - ) - } + private val chatActionMenuItemMargin by lazy { context.resources.getDimens("chat_action_menu_item_margin") } - private val actionMenuItemHeight by lazy { - context.androidContext.resources.getDimensionPixelSize( - context.androidContext.resources.getIdentifier( - "action_menu_item_height", - "dimen", - Constants.SNAPCHAT_PACKAGE_NAME - ) - ) - } + private val actionMenuItemHeight by lazy { context.resources.getDimens("action_menu_item_height") } private fun createContainer(viewGroup: ViewGroup): LinearLayout { val parent = viewGroup.parent.parent as ViewGroup @@ -87,22 +62,11 @@ class ChatActionMenu : AbstractMenu() { get() = context.database.getConversationMessageFromId(context.feature(Messaging::class).lastFocusedMessageId) @SuppressLint("SetTextI18n", "DiscouragedApi", "ClickableViewAccessibility") - fun inject(viewGroup: ViewGroup) { - val parent = viewGroup.parent.parent as? ViewGroup ?: return - if (viewTagState[parent]) return + override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + val viewGroup = parent.parent.parent as? ViewGroup ?: return + if (viewTagState[viewGroup]) return //close the action menu using a touch event - val closeActionMenu = { - viewGroup.dispatchTouchEvent( - MotionEvent.obtain( - SystemClock.uptimeMillis(), - SystemClock.uptimeMillis(), - MotionEvent.ACTION_DOWN, - 0f, - 0f, - 0 - ) - ) - } + val closeActionMenu = { parent.triggerCloseTouchEvent() } val messaging = context.feature(Messaging::class) val messageLogger = context.feature(MessageLogger::class) @@ -123,7 +87,7 @@ class ChatActionMenu : AbstractMenu() { } with(button) { - applyTheme(parent.width, true) + applyTheme(viewGroup.width, true) layoutParams = MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT @@ -168,7 +132,7 @@ class ChatActionMenu : AbstractMenu() { } if (context.isDeveloper) { - parent.addView(createContainer(viewGroup).apply { + viewGroup.addView(createContainer(viewGroup).apply { val debugText = StringBuilder() setOnClickListener { @@ -221,6 +185,6 @@ class ChatActionMenu : AbstractMenu() { }) } - parent.addView(buttonContainer) + viewGroup.addView(buttonContainer) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index d4752a12b..f1ad2a4df 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.view.View +import android.view.ViewGroup import android.widget.Button import android.widget.CompoundButton import android.widget.Switch @@ -221,7 +222,7 @@ class FriendFeedInfoMenu : AbstractMenu() { viewConsumer(switch) } - fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { + override fun inject(parent: ViewGroup, view: View, viewConsumer: ((View) -> Unit)) { val modContext = context val friendFeedMenuOptions by context.config.userInterface.friendFeedMenuButtons @@ -229,19 +230,17 @@ class FriendFeedInfoMenu : AbstractMenu() { val (conversationId, targetUser) = getCurrentConversationInfo() - val previewButton = Button(viewModel.context).apply { - text = modContext.translation["friend_menu_option.preview"] - applyTheme(viewModel.width, hasRadius = true) - setOnClickListener { - showPreview( - targetUser, - conversationId - ) - } - } - if (friendFeedMenuOptions.contains("conversation_info")) { - viewConsumer(previewButton) + viewConsumer(Button(view.context).apply { + text = modContext.translation["friend_menu_option.preview"] + applyTheme(view.width, hasRadius = true) + setOnClickListener { + showPreview( + targetUser, + conversationId + ) + } + }) } modContext.features.getRuleFeatures().forEach { ruleFeature -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt index 012005c24..ebe7fdf73 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt @@ -6,43 +6,42 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.ui.ViewTagState +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.ktx.getIdentifier import java.lang.reflect.Modifier +import kotlin.reflect.KClass @SuppressLint("DiscouragedApi") class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private val viewTagState = ViewTagState() - private val friendFeedInfoMenu = FriendFeedInfoMenu() - private val operaContextActionMenu = OperaContextActionMenu() - private val chatActionMenu = ChatActionMenu() - private val settingMenu = SettingsMenu() - private val settingsGearInjector = SettingsGearInjector() - + private val menuMap = mutableMapOf, AbstractMenu>() private val newChatString by lazy { - context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) + context.resources.getString(context.resources.getIdentifier("new_chat", "string")) } @SuppressLint("ResourceType") override fun asyncOnActivityCreate() { - friendFeedInfoMenu.context = context - operaContextActionMenu.context = context - chatActionMenu.context = context - settingMenu.context = context - settingsGearInjector.context = context + menuMap[SettingsGearInjector::class] = SettingsGearInjector() + menuMap[FriendFeedInfoMenu::class] = FriendFeedInfoMenu() + menuMap[OperaContextActionMenu::class] = OperaContextActionMenu() + menuMap[ChatActionMenu::class] = ChatActionMenu() + menuMap[SettingsMenu::class] = SettingsMenu() + + menuMap.values.forEach { it.context = context } val messaging = context.feature(Messaging::class) - val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val actionSheetContainer = context.resources.getIdentifier("action_sheet_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val actionMenu = context.resources.getIdentifier("action_menu", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val componentsHolder = context.resources.getIdentifier("components_holder", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id", Constants.SNAPCHAT_PACKAGE_NAME) + val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id") + val actionSheetContainer = context.resources.getIdentifier("action_sheet_container", "id") + val actionMenu = context.resources.getIdentifier("action_menu", "id") + val componentsHolder = context.resources.getIdentifier("components_holder", "id") + val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id") context.event.subscribe(AddViewEvent::class) { event -> val originalAddView: (View) -> Unit = { @@ -56,17 +55,17 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar val viewGroup: ViewGroup = event.parent val childView: View = event.view - operaContextActionMenu.inject(event.parent, childView) + menuMap[OperaContextActionMenu::class]!!.inject(viewGroup, childView, originalAddView) if (event.parent.id == componentsHolder && childView.id == feedNewChat) { - settingsGearInjector.inject(event.parent, childView) + menuMap[SettingsGearInjector::class]!!.inject(viewGroup, childView, originalAddView) return@subscribe } //download in chat snaps and notes from the chat action menu if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) { if (viewGroup.parent == null || viewGroup.parent.parent == null) return@subscribe - chatActionMenu.inject(viewGroup) + menuMap[ChatActionMenu::class]!!.inject(viewGroup, childView, originalAddView) return@subscribe } @@ -87,7 +86,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar val viewList = mutableListOf() context.runOnUiThread { - friendFeedInfoMenu.inject(injectedLayout) { view -> + menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, injectedLayout) { view -> view.layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT @@ -124,7 +123,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar //the 3 dot button shows a menu which contains the first item as a Plain object if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=$newChatString")) { - settingMenu.inject(viewGroup, originalAddView) + menuMap[SettingsMenu::class]!!.inject(viewGroup, childView, originalAddView) viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) {} override fun onViewDetachedFromWindow(v: View) { @@ -139,7 +138,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar //filter by the slot index if (viewGroup.getChildCount() != context.config.userInterface.friendFeedMenuPosition.get()) return@subscribe if (viewTagState[viewGroup]) return@subscribe - friendFeedInfoMenu.inject(viewGroup, originalAddView) + menuMap[FriendFeedInfoMenu::class]!!.inject(viewGroup, childView, originalAddView) } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt index ded3a0f46..25445e131 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt @@ -7,16 +7,14 @@ import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout import android.widget.ScrollView -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.ktx.getId @SuppressLint("DiscouragedApi") class OperaContextActionMenu : AbstractMenu() { - private val contextCardsScrollView by lazy { - context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) - } + private val contextCardsScrollView by lazy { context.resources.getId("context_cards_scroll_view") } /* LinearLayout : @@ -52,15 +50,15 @@ class OperaContextActionMenu : AbstractMenu() { } @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup, childView: View) { + override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { try { - if (viewGroup.parent !is ScrollView) return - val parent = viewGroup.parent as ScrollView - if (parent.id != contextCardsScrollView) return - if (childView !is LinearLayout) return - if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return + if (parent.parent !is ScrollView) return + val parentView = parent.parent as ScrollView + if (parentView.id != contextCardsScrollView) return + if (view !is LinearLayout) return + if (!isViewGroupButtonMenuContainer(view as ViewGroup)) return - val linearLayout = LinearLayout(childView.getContext()) + val linearLayout = LinearLayout(view.context) linearLayout.orientation = LinearLayout.VERTICAL linearLayout.gravity = Gravity.CENTER linearLayout.layoutParams = @@ -71,21 +69,21 @@ class OperaContextActionMenu : AbstractMenu() { val translation = context.translation val mediaDownloader = context.feature(MediaDownloader::class) - linearLayout.addView(Button(childView.getContext()).apply { + linearLayout.addView(Button(view.context).apply { text = translation["opera_context_menu.download"] setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync() } applyTheme(isAmoled = false) }) if (context.isDeveloper) { - linearLayout.addView(Button(childView.getContext()).apply { + linearLayout.addView(Button(view.context).apply { text = "Show debug info" setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } applyTheme(isAmoled = false) }) } - (childView as ViewGroup).addView(linearLayout, 0) + (view as ViewGroup).addView(linearLayout, 0) } catch (e: Throwable) { context.log.error("Error while injecting OperaContextActionMenu", e) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt index 75957ddb0..2152ccc82 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt @@ -5,36 +5,21 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView -import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.ktx.getDimens +import me.rhunk.snapenhance.core.util.ktx.getDrawable +import me.rhunk.snapenhance.core.util.ktx.getStyledAttributes @SuppressLint("DiscouragedApi") class SettingsGearInjector : AbstractMenu() { - private val headerButtonOpaqueIconTint by lazy { - context.resources.getIdentifier("headerButtonOpaqueIconTint", "attr", Constants.SNAPCHAT_PACKAGE_NAME).let { - context.androidContext.theme.obtainStyledAttributes(intArrayOf(it)).getColorStateList(0) - } - } - - private val settingsSvg by lazy { - context.resources.getIdentifier("svg_settings_32x32", "drawable", Constants.SNAPCHAT_PACKAGE_NAME).let { - context.resources.getDrawable(it, context.androidContext.theme) - } - } - - private val ngsHovaHeaderSearchIconBackgroundMarginLeft by lazy { - context.resources.getIdentifier("ngs_hova_header_search_icon_background_margin_left", "dimen", Constants.SNAPCHAT_PACKAGE_NAME).let { - context.resources.getDimensionPixelSize(it) - } - } + override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + val firstView = (view as ViewGroup).getChildAt(0) - @SuppressLint("SetTextI18n", "ClickableViewAccessibility") - fun inject(parent: ViewGroup, child: View) { - val firstView = (child as ViewGroup).getChildAt(0) + val ngsHovaHeaderSearchIconBackgroundMarginLeft = context.resources.getDimens("ngs_hova_header_search_icon_background_margin_left") - child.clipChildren = false - child.addView(FrameLayout(parent.context).apply { + view.clipChildren = false + view.addView(FrameLayout(parent.context).apply { layoutParams = FrameLayout.LayoutParams(firstView.layoutParams.width, firstView.layoutParams.height).apply { y = 0f x = -(ngsHovaHeaderSearchIconBackgroundMarginLeft + firstView.layoutParams.width).toFloat() @@ -52,7 +37,7 @@ class SettingsGearInjector : AbstractMenu() { } parent.setOnTouchListener { _, event -> - if (child.visibility == View.INVISIBLE || child.alpha == 0F) return@setOnTouchListener false + if (view.visibility == View.INVISIBLE || view.alpha == 0F) return@setOnTouchListener false val viewLocation = IntArray(2) getLocationOnScreen(viewLocation) @@ -73,8 +58,8 @@ class SettingsGearInjector : AbstractMenu() { layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 17).apply { gravity = android.view.Gravity.CENTER } - setImageDrawable(settingsSvg) - headerButtonOpaqueIconTint?.let { + setImageDrawable(context.resources.getDrawable("svg_settings_32x32", context.theme)) + context.resources.getStyledAttributes("headerButtonOpaqueIconTint", context.theme).getColorStateList(0)?.let { imageTintList = it } }) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt index 5bafc6244..5a370136c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt @@ -1,29 +1,6 @@ package me.rhunk.snapenhance.core.ui.menu.impl -import android.annotation.SuppressLint -import android.view.View import me.rhunk.snapenhance.core.ui.menu.AbstractMenu class SettingsMenu : AbstractMenu() { - //TODO: quick settings - @SuppressLint("SetTextI18n") - @Suppress("UNUSED_PARAMETER") - fun inject(viewModel: View, addView: (View) -> Unit) { - /*val actions = context.actionManager.getActions().map { - Pair(it) { - val button = Button(viewModel.context) - button.text = context.translation[it.nameKey] - - button.setOnClickListener { _ -> - it.run() - } - ViewAppearanceHelper.applyTheme(button) - button - } - } - - actions.forEach { - addView(it.second()) - }*/ - } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt new file mode 100644 index 000000000..c2684ceb8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt @@ -0,0 +1,33 @@ +package me.rhunk.snapenhance.core.util.ktx + +import android.annotation.SuppressLint +import android.content.res.Resources +import android.content.res.Resources.Theme +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import me.rhunk.snapenhance.common.Constants + + +@SuppressLint("DiscouragedApi") +fun Resources.getIdentifier(name: String, type: String): Int { + return getIdentifier(name, type, Constants.SNAPCHAT_PACKAGE_NAME) +} + +@SuppressLint("DiscouragedApi") +fun Resources.getId(name: String): Int { + return getIdentifier(name, "id", Constants.SNAPCHAT_PACKAGE_NAME) +} + +fun Resources.getDimens(name: String): Int { + return getDimensionPixelSize(getIdentifier(name, "dimen")) +} + +fun Resources.getStyledAttributes(name: String, theme: Theme): TypedArray { + return getIdentifier(name, "attr").let { + theme.obtainStyledAttributes(intArrayOf(it)) + } +} + +fun Resources.getDrawable(name: String, theme: Theme): Drawable { + return getDrawable(getIdentifier(name, "drawable"), theme) +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt index 05f4c5c0d..45adb1b06 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/XposedHelperExt.kt @@ -20,7 +20,7 @@ fun Any.setObjectField(fieldName: String, value: Any?) { fun Any.getObjectFieldOrNull(fieldName: String): Any? { return try { getObjectField(fieldName) - } catch (e: Exception) { + } catch (t: Throwable) { null } } From 8d1c9a87ad13dd8d01641cc8d0fad33ab6c6e32e Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:32:06 +0100 Subject: [PATCH 195/274] fix(core/message_exporter): json deflate os --- .../snapenhance/core/messaging/MessageExporter.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt index 9cf5d378f..26ccc3956 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt @@ -192,15 +192,16 @@ class MessageExporter( //write the json file output.write("\n".toByteArray()) From 17f81eb6827e1a966cd6dd19b9d0d6ced095b3e0 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:08:02 +0100 Subject: [PATCH 196/274] refactor: conversation manager wrapper - fix auto save in background --- .../snapenhance/common/data/SnapEnums.kt | 5 + .../snapenhance/common/util/ktx/HashCode.kt | 7 -- .../snapenhance/common/util/ktx/JavaExt.kt | 36 ++++++ .../me/rhunk/snapenhance/core/ModContext.kt | 2 + .../me/rhunk/snapenhance/core/SnapEnhance.kt | 7 +- .../core/action/impl/ExportChatMessages.kt | 31 ++--- .../snapenhance/core/data/SnapClassCache.kt | 1 + .../core/features/impl/messaging/AutoSave.kt | 40 +++--- .../core/features/impl/messaging/Messaging.kt | 9 +- .../features/impl/messaging/Notifications.kt | 52 +++----- .../core/messaging/CoreMessagingBridge.kt | 103 +++++---------- .../snapenhance/core/util/CallbackBuilder.kt | 12 +- .../snapenhance/core/util/ReflectionHelper.kt | 119 ------------------ .../core/wrapper/impl/ConversationManager.kt | 105 ++++++++++++++++ .../snapenhance/core/wrapper/impl/SnapUUID.kt | 2 + .../core/wrapper/impl/media/opera/Layer.kt | 18 ++- .../impl/media/opera/LayerController.kt | 18 --- .../core/wrapper/impl/media/opera/ParamMap.kt | 9 +- 18 files changed, 251 insertions(+), 325 deletions(-) delete mode 100644 common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/HashCode.kt create mode 100644 common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/LayerController.kt diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt index 6545a59d2..10d7447c4 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt @@ -105,6 +105,11 @@ enum class MediaReferenceType { UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO } + +enum class MessageUpdate { + UNKNOWN, READ, RELEASE, SAVE, UNSAVE, ERASE, SCREENSHOT, SCREEN_RECORD, REPLAY, REACTION, REMOVEREACTION, REVOKETRANSCRIPTION, ALLOWTRANSCRIPTION, ERASESAVEDSTORYMEDIA +} + enum class FriendLinkType(val value: Int, val shortName: String) { MUTUAL(0, "mutual"), OUTGOING(1, "outgoing"), diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/HashCode.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/HashCode.kt deleted file mode 100644 index 69d6c41f1..000000000 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/HashCode.kt +++ /dev/null @@ -1,7 +0,0 @@ -package me.rhunk.snapenhance.common.util.ktx - -fun String.longHashCode(): Long { - var h = 1125899906842597L - for (element in this) h = 31 * h + element.code.toLong() - return h -} \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt new file mode 100644 index 000000000..2f0ef53a8 --- /dev/null +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt @@ -0,0 +1,36 @@ +package me.rhunk.snapenhance.common.util.ktx + +import java.lang.reflect.Field + +fun String.longHashCode(): Long { + var h = 1125899906842597L + for (element in this) h = 31 * h + element.code.toLong() + return h +} + +inline fun Class<*>.findFields(once: Boolean, crossinline predicate: (field: Field) -> Boolean): List{ + var clazz: Class<*>? = this + val fields = mutableListOf() + + while (clazz != null) { + if (once) { + clazz.declaredFields.firstOrNull(predicate)?.let { return listOf(it) } + } else { + fields.addAll(clazz.declaredFields.filter(predicate)) + } + clazz = clazz.superclass ?: break + } + + return fields +} + +inline fun Class<*>.findFieldsToString(instance: Any? = null, once: Boolean = false, crossinline predicate: (field: Field, value: String) -> Boolean): List { + return this.findFields(once = once) { + try { + it.isAccessible = true + return@findFields it.get(instance)?.let { it1 -> predicate(it, it1.toString()) } == true + } catch (e: Throwable) { + return@findFields false + } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt index 0d8acf926..9004507a5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -67,6 +67,8 @@ class ModContext( val isDeveloper by lazy { config.scripting.developerMode.get() } + var isMainActivityPaused = false + fun feature(featureClass: KClass): T { return features.get(featureClass)!! } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt index c4218a9de..7d2c0cfcf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -35,7 +35,6 @@ class SnapEnhance { } private lateinit var appContext: ModContext private var isBridgeInitialized = false - private var isActivityPaused = false private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.() -> Unit) { Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param -> @@ -91,14 +90,14 @@ class SnapEnhance { hookMainActivity("onPause") { appContext.bridgeClient.closeSettingsOverlay() - isActivityPaused = true + appContext.isMainActivityPaused = true } var activityWasResumed = false //we need to reload the config when the app is resumed //FIXME: called twice at first launch hookMainActivity("onResume") { - isActivityPaused = false + appContext.isMainActivityPaused = false if (!activityWasResumed) { activityWasResumed = true return@hookMainActivity @@ -175,7 +174,7 @@ class SnapEnhance { } fun runLater(task: () -> Unit) { - if (isActivityPaused) { + if (appContext.isMainActivityPaused) { tasks.add(task) } else { task() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt index 30ba2199c..030cdea30 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt @@ -18,18 +18,11 @@ import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.messaging.ExportFormat import me.rhunk.snapenhance.core.messaging.MessageExporter import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.core.util.CallbackBuilder import me.rhunk.snapenhance.core.wrapper.impl.Message -import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID import java.io.File import kotlin.math.absoluteValue class ExportChatMessages : AbstractAction() { - private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } - private val fetchConversationWithMessagesPaginatedMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } - } - private val dialogLogs = mutableListOf() private var currentActionDialog: AlertDialog? = null @@ -149,24 +142,14 @@ class ExportChatMessages : AbstractAction() { } } - private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int) = suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass) - .override("onFetchConversationWithMessagesComplete") { param -> - val messagesList = param.arg>(1).map { Message(it) } - continuation.resumeWith(Result.success(messagesList)) - } - .override("onServerRequest", shouldUnhook = false) {} - .override("onError") { - continuation.resumeWith(Result.failure(Exception("Failed to fetch messages"))) - }.build() - - fetchConversationWithMessagesPaginatedMethod.invoke( - context.feature(Messaging::class).conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), + private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int): List = suspendCancellableCoroutine { continuation -> + context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(conversationId, lastMessageId, - amount, - callback - ) + amount, onSuccess = { messages -> + continuation.resumeWith(Result.success(messages)) + }, onError = { + continuation.resumeWith(Result.success(emptyList())) + }) ?: continuation.resumeWith(Result.success(emptyList())) } private suspend fun exportFullConversation(friendFeedEntry: FriendFeedEntry) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt index 0175eded2..20c6d5cf2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt @@ -9,6 +9,7 @@ class SnapClassCache ( val presenceSession by lazy { findClass("com.snapchat.talkcorev3.PresenceSession\$CppProxy") } val message by lazy { findClass("com.snapchat.client.messaging.Message") } val messageUpdateEnum by lazy { findClass("com.snapchat.client.messaging.MessageUpdate") } + val serverMessageIdentifier by lazy { findClass("com.snapchat.client.messaging.ServerMessageIdentifier") } val unifiedGrpcService by lazy { findClass("com.snapchat.client.grpc.UnifiedGrpcService\$CppProxy") } val networkApi by lazy { findClass("com.snapchat.client.network_api.NetworkApi\$CppProxy") } val messageDestinations by lazy { findClass("com.snapchat.client.messaging.MessageDestinations") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt index 4c61f5bd5..175d280c0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -1,13 +1,13 @@ package me.rhunk.snapenhance.core.features.impl.messaging import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.MessagingRuleFeature import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.logger.CoreLogger -import me.rhunk.snapenhance.core.util.CallbackBuilder import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.ktx.getObjectField @@ -21,14 +21,6 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, private val messageLogger by lazy { context.feature(MessageLogger::class) } private val messaging by lazy { context.feature(Messaging::class) } - private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } - private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } - - private val updateMessageMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "updateMessage" } } - private val fetchConversationWithMessagesPaginatedMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } - } - private val autoSaveFilter by lazy { context.config.messaging.autoSaveMessagesInConversations.get() } @@ -39,20 +31,17 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, if (message.messageState != MessageState.COMMITTED) return runCatching { - val callback = CallbackBuilder(callbackClass) - .override("onError") { - context.log.warn("Error saving message $messageId") - }.build() - - updateMessageMethod.invoke( - context.feature(Messaging::class).conversationManager, - conversationId.instanceNonNull(), + context.feature(Messaging::class).conversationManager?.updateMessage( + conversationId.toString(), messageId, - context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" }, - callback - ) + MessageUpdate.SAVE + ) { + if (it != null) { + context.log.warn("Error saving message $messageId: $it") + } + } }.onFailure { - CoreLogger.xposedLog("Error saving message $messageId", it) + context.log.error("Error saving message $messageId", it) } //delay between saves @@ -60,6 +49,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, } private fun canSaveMessage(message: Message): Boolean { + if (context.mainActivity == null || context.isMainActivityPaused) return false if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == context.database.myUserId }) return false val contentType = message.messageContent.contentType.toString() @@ -121,14 +111,14 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, HookStage.BEFORE, { autoSaveFilter.isNotEmpty() } ) { - val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() val conversationUUID = messaging.openedConversationUUID ?: return@hook runCatching { - fetchConversationWithMessagesPaginatedMethod.invoke( - messaging.conversationManager, conversationUUID.instanceNonNull(), + messaging.conversationManager?.fetchConversationWithMessagesPaginated( + conversationUUID.toString(), Long.MAX_VALUE, 10, - callback + onSuccess = {}, + onError = {} ) }.onFailure { CoreLogger.xposedLog("failed to save message", it) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index b6c61a961..c38ba4325 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -9,12 +9,13 @@ import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.ConversationManager import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { - private var _conversationManager: Any? = null - val conversationManager: Any? - get() = _conversationManager + var conversationManager: ConversationManager? = null + private set + var openedConversationUUID: SnapUUID? = null private set @@ -28,7 +29,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C override fun init() { Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { param -> - _conversationManager = param.thisObject() + conversationManager = ConversationManager(context, param.thisObject()) context.messagingBridge.triggerSessionStart() context.mainActivity?.takeIf { it.intent.getBooleanExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA,false) }?.run { finishAndRemoveTask() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index c37bf8f9e..43dd1f760 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -14,6 +14,7 @@ import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.MediaReferenceType +import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.common.data.NotificationType import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader @@ -191,31 +192,26 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val conversationManager = context.feature(Messaging::class).conversationManager ?: return@subscribe - context.classCache.conversationManager.methods.first { it.name == "displayedMessages"}?.invoke( - conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), + conversationManager.displayedMessages( + conversationId, messageId, - CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) - .override("onError") { - context.log.error("Failed to mark message as read: ${it.arg(0) as Any}") - context.shortToast("Failed to mark message as read") - }.build() + onResult = { + if (it != null) { + context.log.error("Failed to mark conversation as read: $it") + context.shortToast("Failed to mark conversation as read") + } + } ) val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@subscribe if (conversationMessage.contentType == ContentType.SNAP.id) { - context.classCache.conversationManager.methods.first { it.name == "updateMessage"}?.invoke( - conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), - messageId, - context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "READ" }, - CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) - .override("onError") { - context.log.error("Failed to open snap: ${it.arg(0) as Any}") - context.shortToast("Failed to open snap") - }.build() - ) + conversationManager.updateMessage(conversationId, messageId, MessageUpdate.READ) { + if (it != null) { + context.log.error("Failed to open snap: $it") + context.shortToast("Failed to open snap") + } + } } }.onFailure { context.log.error("Failed to mark message as read", it) @@ -346,8 +342,6 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN override fun init() { setupBroadcastReceiverHook() - val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") - notifyAsUserMethod.hook(HookStage.BEFORE) { param -> val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) @@ -361,22 +355,16 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN notificationType.contains(it) }) return@hook - val conversationManager: Any = context.feature(Messaging::class).conversationManager ?: return@hook - synchronized(notificationDataQueue) { notificationDataQueue[messageId.toLong()] = notificationData } - val callback = CallbackBuilder(fetchConversationWithMessagesCallback) - .override("onFetchConversationWithMessagesComplete") { callbackParam -> - val messageList = (callbackParam.arg(1) as List).map { msg -> Message(msg) } - fetchMessagesResult(conversationId, messageList) - } - .override("onError") { - context.log.error("Failed to fetch message ${it.arg(0) as Any}") - }.build() + context.feature(Messaging::class).conversationManager?.fetchConversationWithMessages(conversationId, onSuccess = { messages -> + fetchMessagesResult(conversationId, messages) + }, onError = { + context.log.error("Failed to fetch conversation with messages: $it") + }) - fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instanceNonNull(), callback) param.setResult(null) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt index f8e3acb74..f0503f585 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt @@ -5,11 +5,10 @@ import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge import me.rhunk.snapenhance.bridge.snapclient.SessionStartListener import me.rhunk.snapenhance.bridge.snapclient.types.Message +import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.features.impl.messaging.Messaging -import me.rhunk.snapenhance.core.util.CallbackBuilder -import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID fun me.rhunk.snapenhance.core.wrapper.impl.Message.toBridge(): Message { @@ -46,23 +45,14 @@ class CoreMessagingBridge( override fun fetchMessage(conversationId: String, clientMessageId: String): Message? { return runBlocking { suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder( - context.mappings.getMappedClass("callbacks", "FetchMessageCallback") - ).override("onFetchMessageComplete") { param -> - val message = me.rhunk.snapenhance.core.wrapper.impl.Message(param.arg(0)).toBridge() - continuation.resumeWith(Result.success(message)) - } - .override("onServerRequest", shouldUnhook = false) {} - .override("onError") { - continuation.resumeWith(Result.success(null)) - }.build() - - context.classCache.conversationManager.methods.first { it.name == "fetchMessage" }.invoke( - conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), + conversationManager?.fetchMessage( + conversationId, clientMessageId.toLong(), - callback - ) + onSuccess = { + continuation.resumeWith(Result.success(it.toBridge())) + }, + onError = { continuation.resumeWith(Result.success(null)) } + ) ?: continuation.resumeWith(Result.success(null)) } } } @@ -73,26 +63,14 @@ class CoreMessagingBridge( ): Message? { return runBlocking { suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder( - context.mappings.getMappedClass("callbacks", "FetchMessageCallback") - ).override("onFetchMessageComplete") { param -> - val message = me.rhunk.snapenhance.core.wrapper.impl.Message(param.arg(1)).toBridge() - continuation.resumeWith(Result.success(message)) - } - .override("onServerRequest", shouldUnhook = false) {} - .override("onError") { - continuation.resumeWith(Result.success(null)) - }.build() - - val serverMessageIdentifier = context.androidContext.classLoader.loadClass("com.snapchat.client.messaging.ServerMessageIdentifier") - .getConstructor(context.classCache.snapUUID, Long::class.javaPrimitiveType) - .newInstance(SnapUUID.fromString(conversationId).instanceNonNull(), serverMessageId.toLong()) - - context.classCache.conversationManager.methods.first { it.name == "fetchMessageByServerId" }.invoke( - conversationManager, - serverMessageIdentifier, - callback - ) + conversationManager?.fetchMessageByServerId( + conversationId, + serverMessageId, + onSuccess = { + continuation.resumeWith(Result.success(it.toBridge())) + }, + onError = { continuation.resumeWith(Result.success(null)) } + ) ?: continuation.resumeWith(Result.success(null)) } } } @@ -104,26 +82,17 @@ class CoreMessagingBridge( ): List? { return runBlocking { suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder( - context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") - ).override("onFetchConversationWithMessagesComplete") { param -> - val messagesList = param.arg>(1).map { - me.rhunk.snapenhance.core.wrapper.impl.Message(it).toBridge() - } - continuation.resumeWith(Result.success(messagesList)) - } - .override("onServerRequest", shouldUnhook = false) {} - .override("onError") { - continuation.resumeWith(Result.success(null)) - }.build() - - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" }.invoke( - conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), + conversationManager?.fetchConversationWithMessagesPaginated( + conversationId, beforeMessageId, limit, - callback - ) + onSuccess = { messages -> + continuation.resumeWith(Result.success(messages.map { it.toBridge() })) + }, + onError = { + continuation.resumeWith(Result.success(null)) + } + ) ?: continuation.resumeWith(Result.success(null)) } } } @@ -135,22 +104,14 @@ class CoreMessagingBridge( ): String? { return runBlocking { suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder( - context.mappings.getMappedClass("callbacks", "Callback") - ).override("onSuccess") { - continuation.resumeWith(Result.success(null)) - } - .override("onError") { - continuation.resumeWith(Result.success(it.arg(0).toString())) - }.build() - - context.classCache.conversationManager.methods.first { it.name == "updateMessage" }.invoke( - conversationManager, - SnapUUID.fromString(conversationId).instanceNonNull(), + conversationManager?.updateMessage( + conversationId, clientMessageId, - context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == messageUpdate }, - callback - ) + MessageUpdate.valueOf(messageUpdate), + onResult = { + continuation.resumeWith(Result.success(it)) + } + ) ?: continuation.resumeWith(Result.success("ConversationManager is null")) } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt index e4f15c489..97fce302b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt @@ -26,10 +26,10 @@ class CallbackBuilder( fun build(): Any { //get the first param of the first constructor to get the class of the invoker - val invokerClass: Class<*> = callbackClass.constructors[0].parameterTypes[0] - //get the invoker field based on the invoker class - val invokerField = callbackClass.fields.first { field: Field -> - field.type.isAssignableFrom(invokerClass) + val rxEmitter: Class<*> = callbackClass.constructors[0].parameterTypes[0] + //get the emitter field based on the class + val rxEmitterField = callbackClass.fields.first { field: Field -> + field.type.isAssignableFrom(rxEmitter) } //get the callback field based on the callback class val callbackInstance = createEmptyObject(callbackClass.constructors[0])!! @@ -44,8 +44,8 @@ class CallbackBuilder( //default hook that unhooks the callback and returns null val defaultHook: (HookAdapter) -> Boolean = defaultHook@{ - //checking invokerField ensure that's the callback was created by the CallbackBuilder - if (invokerField.get(it.thisObject()) != null) return@defaultHook false + //ensure that's the callback was created by the CallbackBuilder + if (rxEmitterField.get(it.thisObject()) != null) return@defaultHook false if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false it.setResult(null) true diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt deleted file mode 100644 index b2f910ef3..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ReflectionHelper.kt +++ /dev/null @@ -1,119 +0,0 @@ -package me.rhunk.snapenhance.core.util - -import java.lang.reflect.Field -import java.lang.reflect.Method -import java.util.Arrays -import java.util.Objects - -object ReflectionHelper { - /** - * Searches for a field with a class that has a method with the specified name - */ - fun searchFieldWithClassMethod(clazz: Class<*>, methodName: String): Field? { - return clazz.declaredFields.firstOrNull { f: Field? -> - try { - return@firstOrNull Arrays.stream( - f!!.type.declaredMethods - ).anyMatch { method: Method -> method.name == methodName } - } catch (e: Exception) { - return@firstOrNull false - } - } - } - - fun searchFieldByType(clazz: Class<*>, type: Class<*>): Field? { - return clazz.declaredFields.firstOrNull { f: Field? -> f!!.type == type } - } - - fun searchFieldTypeInSuperClasses(clazz: Class<*>, type: Class<*>): Field? { - val field = searchFieldByType(clazz, type) - if (field != null) { - return field - } - val superclass = clazz.superclass - return superclass?.let { searchFieldTypeInSuperClasses(it, type) } - } - - fun searchFieldStartsWithToString( - clazz: Class<*>, - instance: Any, - toString: String? - ): Field? { - return clazz.declaredFields.firstOrNull { f: Field -> - try { - f.isAccessible = true - return@firstOrNull Objects.requireNonNull(f[instance]).toString() - .startsWith( - toString!! - ) - } catch (e: Throwable) { - return@firstOrNull false - } - } - } - - - fun searchFieldContainsToString( - clazz: Class<*>, - instance: Any?, - toString: String? - ): Field? { - return clazz.declaredFields.firstOrNull { f: Field -> - try { - f.isAccessible = true - return@firstOrNull Objects.requireNonNull(f[instance]).toString() - .contains(toString!!) - } catch (e: Throwable) { - return@firstOrNull false - } - } - } - - fun searchFirstFieldTypeInClassRecursive(clazz: Class<*>, type: Class<*>): Field? { - return clazz.declaredFields.firstOrNull { - val field = searchFieldByType(it.type, type) - return@firstOrNull field != null - } - } - - /** - * Searches for a field with a class that has a method with the specified return type - */ - fun searchMethodWithReturnType(clazz: Class<*>, returnType: Class<*>): Method? { - return clazz.declaredMethods.first { m: Method -> m.returnType == returnType } - } - - /** - * Searches for a field with a class that has a method with the specified return type and parameter types - */ - fun searchMethodWithParameterAndReturnType( - aClass: Class<*>, - returnType: Class<*>, - vararg parameters: Class<*> - ): Method? { - return aClass.declaredMethods.firstOrNull { m: Method -> - if (m.returnType != returnType) { - return@firstOrNull false - } - val parameterTypes = m.parameterTypes - if (parameterTypes.size != parameters.size) { - return@firstOrNull false - } - for (i in parameterTypes.indices) { - if (parameterTypes[i] != parameters[i]) { - return@firstOrNull false - } - } - true - } - } - - fun getDeclaredFieldsRecursively(clazz: Class<*>): List { - val fields = clazz.declaredFields.toMutableList() - val superclass = clazz.superclass - if (superclass != null) { - fields.addAll(getDeclaredFieldsRecursively(superclass)) - } - return fields - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt new file mode 100644 index 000000000..5e338e4fe --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -0,0 +1,105 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +typealias CallbackResult = (error: String?) -> Unit + +class ConversationManager(val context: ModContext, obj: Any) : AbstractWrapper(obj) { + private fun findMethodByName(name: String) = context.classCache.conversationManager.declaredMethods.find { it.name == name } ?: throw RuntimeException("Could not find method $name") + + private val updateMessageMethod by lazy { findMethodByName("updateMessage") } + private val fetchConversationWithMessagesPaginatedMethod by lazy { findMethodByName("fetchConversationWithMessagesPaginated") } + private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") } + private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") } + private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } + private val fetchMessage by lazy { findMethodByName("fetchMessage") } + + + fun updateMessage(conversationId: String, messageId: Long, action: MessageUpdate, onResult: CallbackResult = {}) { + updateMessageMethod.invoke( + instanceNonNull(), + SnapUUID.fromString(conversationId).instanceNonNull(), + messageId, + context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == action.toString() }, + CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) + .override("onSuccess") { onResult(null) } + .override("onError") { onResult(it.arg(0).toString()) }.build() + ) + } + + fun fetchConversationWithMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int, onSuccess: (message: List) -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback")) + .override("onFetchConversationWithMessagesComplete") { param -> + onSuccess(param.arg>(1).map { Message(it) }) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + fetchConversationWithMessagesPaginatedMethod.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), lastMessageId, amount, callback) + } + + fun fetchConversationWithMessages(conversationId: String, onSuccess: (List) -> Unit, onError: (error: String) -> Unit) { + fetchConversationWithMessagesMethod.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback")) + .override("onFetchConversationWithMessagesComplete") { param -> + onSuccess(param.arg>(1).map { Message(it) }) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun displayedMessages(conversationId: String, messageId: Long, onResult: CallbackResult = {}) { + displayedMessagesMethod.invoke( + instanceNonNull(), + conversationId.toSnapUUID(), + messageId, + CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) + .override("onSuccess") { onResult(null) } + .override("onError") { onResult(it.arg(0).toString()) }.build() + ) + } + + fun fetchMessage(conversationId: String, messageId: Long, onSuccess: (Message) -> Unit, onError: (error: String) -> Unit = {}) { + fetchMessage.invoke( + instanceNonNull(), + conversationId.toSnapUUID().instanceNonNull(), + messageId, + CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchMessageCallback")) + .override("onSuccess") { param -> + onSuccess(Message(param.arg(0))) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + + fun fetchMessageByServerId(conversationId: String, serverMessageId: String, onSuccess: (Message) -> Unit, onError: (error: String) -> Unit) { + val serverMessageIdentifier = context.classCache.serverMessageIdentifier + .getConstructor(context.classCache.snapUUID, Long::class.javaPrimitiveType) + .newInstance(conversationId.toSnapUUID().instanceNonNull(), serverMessageId.toLong()) + + fetchMessageByServerId.invoke( + instanceNonNull(), + serverMessageIdentifier, + CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchMessageCallback")) + .override("onFetchMessageComplete") { param -> + onSuccess(Message(param.arg(1))) + } + .override("onServerRequest", shouldUnhook = false) {} + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt index 8af2e98e0..66a729ca5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/SnapUUID.kt @@ -6,6 +6,8 @@ import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import java.nio.ByteBuffer import java.util.UUID +fun String.toSnapUUID() = SnapUUID.fromString(this) + class SnapUUID(obj: Any?) : AbstractWrapper(obj) { private val uuidString by lazy { toUUID().toString() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt index 747a18015..665852975 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/Layer.kt @@ -1,21 +1,19 @@ package me.rhunk.snapenhance.core.wrapper.impl.media.opera -import me.rhunk.snapenhance.core.util.ReflectionHelper +import me.rhunk.snapenhance.common.util.ktx.findFieldsToString import me.rhunk.snapenhance.core.wrapper.AbstractWrapper class Layer(obj: Any?) : AbstractWrapper(obj) { val paramMap: ParamMap get() { - val layerControllerField = ReflectionHelper.searchFieldContainsToString( - instanceNonNull()::class.java, - instance, - "OperaPageModel" - )!! + val layerControllerField = instanceNonNull()::class.java.findFieldsToString(instance, once = true) { _, value -> + value.contains("OperaPageModel") + }.firstOrNull() ?: throw RuntimeException("Could not find layerController field") + + val paramsMapHashMap = layerControllerField.type.findFieldsToString(layerControllerField[instance], once = true) { _, value -> + value.contains("OperaPageModel") + }.firstOrNull() ?: throw RuntimeException("Could not find paramsMap field") - val paramsMapHashMap = ReflectionHelper.searchFieldStartsWithToString( - layerControllerField.type, - layerControllerField[instance] as Any, "OperaPageModel" - )!! return ParamMap(paramsMapHashMap[layerControllerField[instance]]!!) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/LayerController.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/LayerController.kt deleted file mode 100644 index b5b1b6f22..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/LayerController.kt +++ /dev/null @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.core.wrapper.impl.media.opera - -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.core.util.ReflectionHelper -import me.rhunk.snapenhance.core.wrapper.AbstractWrapper -import java.lang.reflect.Field -import java.util.concurrent.ConcurrentHashMap - -class LayerController(obj: Any?) : AbstractWrapper(obj) { - val paramMap: ParamMap - get() { - val paramMapField: Field = ReflectionHelper.searchFieldTypeInSuperClasses( - instanceNonNull()::class.java, - ConcurrentHashMap::class.java - ) ?: throw RuntimeException("Could not find paramMap field") - return ParamMap(XposedHelpers.getObjectField(instance, paramMapField.name)) - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt index 6b0be41be..64109d43e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/opera/ParamMap.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.wrapper.impl.media.opera -import me.rhunk.snapenhance.core.util.ReflectionHelper +import me.rhunk.snapenhance.common.util.ktx.findFields import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import java.lang.reflect.Field @@ -9,10 +9,9 @@ import java.util.concurrent.ConcurrentHashMap @Suppress("UNCHECKED_CAST") class ParamMap(obj: Any?) : AbstractWrapper(obj) { private val paramMapField: Field by lazy { - ReflectionHelper.searchFieldTypeInSuperClasses( - instanceNonNull().javaClass, - ConcurrentHashMap::class.java - )!! + instanceNonNull()::class.java.findFields(once = true) { + it.type == ConcurrentHashMap::class.java + }.firstOrNull() ?: throw RuntimeException("Could not find paramMap field") } val concurrentHashMap: ConcurrentHashMap From a63bca978238ffc6adec2997e1c3f7e30c9c4727 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:51:19 +0100 Subject: [PATCH 197/274] feat: instant delete --- common/src/main/assets/lang/en_US.json | 4 + .../common/config/impl/MessagingTweaks.kt | 1 + .../features/impl/messaging/InstantDelete.kt | 105 ++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 2 +- .../core/wrapper/impl/ConversationManager.kt | 4 +- .../core/wrapper/impl/MessageContent.kt | 2 + .../core/wrapper/impl/QuotedMessage.kt | 8 ++ .../core/wrapper/impl/QuotedMessageContent.kt | 10 ++ 8 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 43b9b7c9c..32cef7326 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -344,6 +344,10 @@ "name": "Prevent Message Sending", "description": "Prevents sending certain types of messages" }, + "instant_delete": { + "name": "Instant Delete", + "description": "Removes the confirmation dialog when deleting messages" + }, "better_notifications": { "name": "Better Notifications", "description": "Adds more information in received notifications" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index b69dc1d10..245426166 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -23,6 +23,7 @@ class MessagingTweaks : ConfigContainer() { customOptionTranslationPath = "features.options.notifications" nativeHooks() } + val instantDelete = boolean("instant_delete") { requireRestart() } val betterNotifications = multiple("better_notifications", "snap", "chat", "reply_button", "download_button", "mark_as_read_button", "group") { requireRestart() } val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt new file mode 100644 index 000000000..8ca3e100e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt @@ -0,0 +1,105 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.view.View +import android.widget.TextView +import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.iterateParent +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.CallbackResult +import java.lang.reflect.Modifier + +class InstantDelete : Feature("InstantDelete", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + if (!context.config.messaging.instantDelete.get()) return + val chatActionMenuOptions = listOf( + "chat_action_menu_erase_messages", + "chat_action_menu_erase_quote", + "chat_action_menu_erase_reply", + ).associateWith { context.resources.getString(context.resources.getIdentifier(it, "string")) } + + val chatActionMenuContainerID = context.resources.getId("chat_action_menu_container") + val actionMenuOptionId = context.resources.getId("action_menu_option") + val actionMenuOptionTextId = context.resources.getId("action_menu_option_text") + + context.event.subscribe(BindViewEvent::class) { event -> + if (event.view.id != actionMenuOptionId) return@subscribe + + val menuOptionText = event.view.findViewById(actionMenuOptionTextId) ?: return@subscribe + if (!chatActionMenuOptions.values.contains(menuOptionText.text)) return@subscribe + + val viewModel = event.prevModel + + val nestedViewOnClickListenerField = viewModel::class.java.fields.find { + it.type == View.OnClickListener::class.java + } ?: return@subscribe + + val nestedViewOnClickListener = nestedViewOnClickListenerField.get(viewModel) as? View.OnClickListener ?: return@subscribe + + val chatViewModel = nestedViewOnClickListener::class.java.fields.find { + Modifier.isAbstract(it.type.modifiers) && runCatching { + it.get(nestedViewOnClickListener) + }.getOrNull().toString().startsWith("ChatViewModel") + }?.get(nestedViewOnClickListener) ?: return@subscribe + + //[convId]:arroyo-id:[messageId] + val (conversationId, messageId) = chatViewModel.toString().substringAfter("messageId=").substringBefore(",").split(":").let { + if (it.size != 3) return@let null + it[0] to it[2] + } ?: return@subscribe + + viewModel.setObjectField(nestedViewOnClickListenerField.name, View.OnClickListener { view -> + val onCallbackResult: CallbackResult = callbackResult@{ + if (it == null || it == "DUPLICATEREQUEST") return@callbackResult + context.log.error("Error deleting message $messageId: $it") + context.shortToast("Error deleting message $messageId: $it. Using fallback method") + context.runOnUiThread { + nestedViewOnClickListener.onClick(view) + } + } + + runCatching { + val conversationManager = context.feature(Messaging::class).conversationManager ?: return@runCatching + + if (chatActionMenuOptions["chat_action_menu_erase_quote"] == menuOptionText.text) { + conversationManager.fetchMessage(conversationId, messageId.toLong(), onSuccess = { message -> + val quotedMessage = message.messageContent.quotedMessage.takeIf { it.isPresent() }!! + + conversationManager.updateMessage( + conversationId, + quotedMessage.content.messageId, + MessageUpdate.ERASE, + onResult = onCallbackResult + ) + }, onError = { + onCallbackResult(it) + }) + return@runCatching + } + + conversationManager.updateMessage( + conversationId, + messageId.toLong(), + MessageUpdate.ERASE, + onResult = onCallbackResult + ) + }.onFailure { + context.log.error("Error deleting message $messageId", it) + onCallbackResult(it.message) + return@OnClickListener + } + + view.iterateParent { + if (it.id != chatActionMenuContainerID) return@iterateParent false + it.triggerCloseTouchEvent() + true + } + }) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 7b84335b6..7678df5e2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -15,7 +15,6 @@ import me.rhunk.snapenhance.core.features.impl.experiments.* import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.messaging.* import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.core.features.impl.experiments.SnapToChatMedia import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.core.features.impl.ui.* @@ -103,6 +102,7 @@ class FeatureManager( HideQuickAddFriendFeed::class, CallStartConfirmation::class, SnapPreview::class, + InstantDelete::class, ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt index 5e338e4fe..08fb84d30 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -74,10 +74,9 @@ class ConversationManager(val context: ModContext, obj: Any) : AbstractWrapper(o conversationId.toSnapUUID().instanceNonNull(), messageId, CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchMessageCallback")) - .override("onSuccess") { param -> + .override("onFetchMessageComplete") { param -> onSuccess(Message(param.arg(0))) } - .override("onServerRequest", shouldUnhook = false) {} .override("onError") { onError(it.arg(0).toString()) }.build() @@ -96,7 +95,6 @@ class ConversationManager(val context: ModContext, obj: Any) : AbstractWrapper(o .override("onFetchMessageComplete") { param -> onSuccess(Message(param.arg(1))) } - .override("onServerRequest", shouldUnhook = false) {} .override("onError") { onError(it.arg(0).toString()) }.build() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt index 9cfe5e5dd..851145d15 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt @@ -9,5 +9,7 @@ class MessageContent(obj: Any?) : AbstractWrapper(obj) { var content get() = instanceNonNull().getObjectField("mContent") as ByteArray set(value) = instanceNonNull().setObjectField("mContent", value) + val quotedMessage + get() = QuotedMessage(instanceNonNull().getObjectField("mQuotedMessage")) var contentType by enum("mContentType", ContentType.UNKNOWN) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt new file mode 100644 index 000000000..c0868baa8 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class QuotedMessage(obj: Any?) : AbstractWrapper(obj) { + val content get() = QuotedMessageContent(instanceNonNull().getObjectField("mContent")) +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt new file mode 100644 index 000000000..137fbea09 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class QuotedMessageContent(obj: Any?) : AbstractWrapper(obj) { + var messageId get() = instanceNonNull().getObjectField("mMessageId") as Long + set(value) = instanceNonNull().setObjectField("mMessageId", value) +} \ No newline at end of file From 3fc06550bf02d96f845abe0b541e775d8c2378e4 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 16:22:00 +0100 Subject: [PATCH 198/274] fix(core/e2ee): ignore encryption of stories --- .../impl/experiments/EndToEndEncryption.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt index 1d4d1b45d..a5dbb9ccd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt @@ -369,7 +369,7 @@ class EndToEndEncryption : MessagingRuleFeature( } private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent) { - val (contentType, buffer) = tryDecryptMessage(senderId, messageId, conversationId, messageContent.contentType ?: ContentType.CHAT, messageContent.content) + val (contentType, buffer) = tryDecryptMessage(senderId, messageId, conversationId, messageContent.contentType ?: ContentType.CHAT, messageContent.content!!) messageContent.contentType = contentType messageContent.content = buffer } @@ -383,11 +383,11 @@ class EndToEndEncryption : MessagingRuleFeature( val messageContent = event.messageContent val destinations = event.destinations - val e2eeConversations = destinations.conversations.filter { getState(it.toString()) && getE2EParticipants(it.toString()).isNotEmpty() } + val e2eeConversations = destinations.conversations!!.filter { getState(it.toString()) && getE2EParticipants(it.toString()).isNotEmpty() } if (e2eeConversations.isEmpty()) return@subscribe - if (e2eeConversations.size != destinations.conversations.size) { + if (e2eeConversations.size != destinations.conversations!!.size || destinations.stories?.isNotEmpty() == true) { if (!forceMessageEncryption) return@subscribe context.longToast("You can't send encrypted content to both encrypted and unencrypted conversations!") event.canceled = true @@ -414,12 +414,22 @@ class EndToEndEncryption : MessagingRuleFeature( context.event.subscribe(UnaryCallEvent::class) { event -> if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe val protoReader = ProtoReader(event.buffer) + var hasStory = false val conversationIds = mutableListOf() protoReader.eachBuffer(3) { + if (contains(2)) { + hasStory = true + return@eachBuffer + } conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer)) } + if (hasStory) { + context.log.debug("Skipping encryption for story message") + return@subscribe + } + if (conversationIds.any { !getState(it.toString()) || getE2EParticipants(it.toString()).isEmpty() }) { context.log.debug("Skipping encryption for conversation ids: ${conversationIds.joinToString(", ")}") return@subscribe @@ -490,15 +500,15 @@ class EndToEndEncryption : MessagingRuleFeature( context.event.subscribe(BuildMessageEvent::class, priority = 0) { event -> val message = event.message if (message.messageState != MessageState.COMMITTED) return@subscribe - val conversationId = message.messageDescriptor.conversationId.toString() + val conversationId = message.messageDescriptor!!.conversationId.toString() messageHook( conversationId = conversationId, - messageId = message.messageDescriptor.messageId, + messageId = message.messageDescriptor!!.messageId!!, senderId = message.senderId.toString(), - messageContent = message.messageContent + messageContent = message.messageContent!! ) - message.messageContent.instanceNonNull() + message.messageContent!!.instanceNonNull() .getObjectField("mQuotedMessage") ?.getObjectField("mContent") ?.also { quotedMessage -> From 07daeaf994258139c83e145b5ff290e5aac1493f Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 2 Nov 2023 16:26:08 +0100 Subject: [PATCH 199/274] refactor: wrappers --- .../core/action/impl/ExportChatMessages.kt | 2 +- .../core/features/impl/ScopeSync.kt | 2 +- .../impl/downloader/decoder/MessageDecoder.kt | 2 +- .../impl/experiments/SnapToChatMedia.kt | 6 +-- .../global/BypassVideoLengthRestriction.kt | 2 +- .../core/features/impl/messaging/AutoSave.kt | 10 ++--- .../features/impl/messaging/InstantDelete.kt | 4 +- .../features/impl/messaging/Notifications.kt | 11 +++--- .../features/impl/messaging/SendOverride.kt | 2 +- .../impl/messaging/UnlimitedSnapViewTime.kt | 6 +-- .../features/impl/spying/MessageLogger.kt | 8 ++-- .../core/messaging/CoreMessagingBridge.kt | 12 +++--- .../core/messaging/MessageExporter.kt | 36 +++++++++--------- .../core/messaging/MessageSender.kt | 8 ++-- .../core/wrapper/AbstractWrapper.kt | 26 ++++++++++++- .../core/wrapper/impl/ConversationManager.kt | 5 ++- .../core/wrapper/impl/FriendActionButton.kt | 38 ------------------- .../snapenhance/core/wrapper/impl/Message.kt | 19 +++++++--- .../core/wrapper/impl/MessageContent.kt | 14 +++---- .../core/wrapper/impl/MessageDescriptor.kt | 8 ++-- .../core/wrapper/impl/MessageDestinations.kt | 11 ++---- .../core/wrapper/impl/MessageMetadata.kt | 34 ++++++++--------- .../core/wrapper/impl/QuotedMessage.kt | 6 ++- .../core/wrapper/impl/QuotedMessageContent.kt | 8 ++-- .../core/wrapper/impl/UserIdToReaction.kt | 16 ++++++-- 25 files changed, 149 insertions(+), 147 deletions(-) delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/FriendActionButton.kt diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt index 030cdea30..ee93a5af0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt @@ -176,7 +176,7 @@ class ExportChatMessages : AbstractAction() { } fetchedMessages.firstOrNull()?.let { - lastMessageId = it.messageDescriptor.messageId + lastMessageId = it.messageDescriptor!!.messageId!! } setStatus("Exporting (${foundMessages.size} / ${foundMessages.firstOrNull()?.orderKey})") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt index d4e67b1c7..685a9846c 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ScopeSync.kt @@ -29,7 +29,7 @@ class ScopeSync : Feature("Scope Sync", loadParams = FeatureLoadParams.INIT_SYNC if (event.messageContent.contentType != ContentType.SNAP) return@subscribe event.addCallbackResult("onSuccess") { - event.destinations.conversations.map { it.toString() }.forEach { conversationId -> + event.destinations.conversations!!.map { it.toString() }.forEach { conversationId -> updateJobs[conversationId]?.also { it.cancel() } updateJobs[conversationId] = (context.coroutineScope.launch { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt index a1c3aaecd..21d375bf3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt @@ -77,7 +77,7 @@ object MessageDecoder { fun decode(messageContent: MessageContent): List { return decode( - ProtoReader(messageContent.content), + ProtoReader(messageContent.content!!), customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt index 6a23c0fac..ea56748ce 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt @@ -12,10 +12,10 @@ class SnapToChatMedia : Feature("SnapToChatMedia", loadParams = FeatureLoadParam if (!context.config.experimental.snapToChatMedia.get()) return context.event.subscribe(BuildMessageEvent::class, priority = 100) { event -> - if (event.message.messageContent.contentType != ContentType.SNAP) return@subscribe + if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe - val snapMessageContent = ProtoReader(event.message.messageContent.content).followPath(11)?.getBuffer() ?: return@subscribe - event.message.messageContent.content = ProtoWriter().apply { + val snapMessageContent = ProtoReader(event.message.messageContent!!.content!!).followPath(11)?.getBuffer() ?: return@subscribe + event.message.messageContent!!.content = ProtoWriter().apply { from(3) { addBuffer(3, snapMessageContent) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt index 1662bd233..a34c2d5c4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt @@ -40,7 +40,7 @@ class BypassVideoLengthRestriction : }) context.event.subscribe(SendMessageWithContentEvent::class) { event -> - if (event.destinations.stories.isEmpty()) return@subscribe + if (event.destinations.stories!!.isEmpty()) return@subscribe fileObserver.startWatching() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt index 175d280c0..33629ede5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -26,8 +26,8 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, } private fun saveMessage(conversationId: SnapUUID, message: Message) { - val messageId = message.messageDescriptor.messageId - if (messageLogger.takeIf { it.isEnabled }?.isMessageDeleted(conversationId.toString(), message.messageDescriptor.messageId) == true) return + val messageId = message.messageDescriptor!!.messageId!! + if (messageLogger.takeIf { it.isEnabled }?.isMessageDeleted(conversationId.toString(), messageId) == true) return if (message.messageState != MessageState.COMMITTED) return runCatching { @@ -50,8 +50,8 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, private fun canSaveMessage(message: Message): Boolean { if (context.mainActivity == null || context.isMainActivityPaused) return false - if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == context.database.myUserId }) return false - val contentType = message.messageContent.contentType.toString() + if (message.messageMetadata!!.savedBy!!.any { uuid -> uuid.toString() == context.database.myUserId }) return false + val contentType = message.messageContent!!.contentType.toString() return autoSaveFilter.any { it == contentType } } @@ -96,7 +96,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, { autoSaveFilter.isNotEmpty() } ) { param -> val message = Message(param.arg(0)) - val conversationId = message.messageDescriptor.conversationId + val conversationId = message.messageDescriptor!!.conversationId!! if (!canSaveInConversation(conversationId.toString())) return@hook if (!canSaveMessage(message)) return@hook diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt index 8ca3e100e..5c3c609c2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/InstantDelete.kt @@ -68,11 +68,11 @@ class InstantDelete : Feature("InstantDelete", loadParams = FeatureLoadParams.AC if (chatActionMenuOptions["chat_action_menu_erase_quote"] == menuOptionText.text) { conversationManager.fetchMessage(conversationId, messageId.toLong(), onSuccess = { message -> - val quotedMessage = message.messageContent.quotedMessage.takeIf { it.isPresent() }!! + val quotedMessage = message.messageContent!!.quotedMessage!!.takeIf { it.isPresent() }!! conversationManager.updateMessage( conversationId, - quotedMessage.content.messageId, + quotedMessage.content!!.messageId!!, MessageUpdate.ERASE, onResult = onCallbackResult ) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index 43dd1f760..43af54b9d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -27,7 +27,6 @@ import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.features.impl.spying.StealthMode -import me.rhunk.snapenhance.core.util.CallbackBuilder import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.setObjectField @@ -264,14 +263,14 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } - val contentType = snapMessage.messageContent.contentType ?: return@onEach - val contentData = snapMessage.messageContent.content + val contentType = snapMessage.messageContent!!.contentType ?: return@onEach + 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, conversationId)} - setupNotificationActionButtons(contentType, conversationId, snapMessage.messageDescriptor.messageId, notificationData) + setupNotificationActionButtons(contentType, conversationId, snapMessage.messageDescriptor!!.messageId!!, notificationData) when (contentType) { ContentType.NOTE -> { @@ -286,14 +285,14 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { val mediaReferences = MessageDecoder.getMediaReferences( - messageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()) + messageContent = context.gson.toJsonTree(snapMessage.messageContent!!.instanceNonNull()) ) val mediaReferenceKeys = mediaReferences.map { reference -> reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() } - MessageDecoder.decode(snapMessage.messageContent).firstOrNull()?.also { media -> + MessageDecoder.decode(snapMessage.messageContent!!).firstOrNull()?.also { media -> val mediaType = MediaReferenceType.valueOf(mediaReferences.first().asJsonObject["mMediaType"].asString) runCatching { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt index bd2ea3d8c..376703655 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -58,7 +58,7 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA) return@subscribe //prevent story replies - val messageProtoReader = ProtoReader(localMessageContent.content) + val messageProtoReader = ProtoReader(localMessageContent.content!!) if (messageProtoReader.contains(7)) return@subscribe event.canceled = true diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt index 6016a97a3..a3c49f959 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/UnlimitedSnapViewTime.kt @@ -15,13 +15,13 @@ class UnlimitedSnapViewTime : context.event.subscribe(BuildMessageEvent::class, { state }, priority = 101) { event -> if (event.message.messageState != MessageState.COMMITTED) return@subscribe - if (event.message.messageContent.contentType != ContentType.SNAP) return@subscribe + if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe val messageContent = event.message.messageContent - val mediaAttributes = ProtoReader(messageContent.content).followPath(11, 5, 2) ?: return@subscribe + val mediaAttributes = ProtoReader(messageContent!!.content!!).followPath(11, 5, 2) ?: return@subscribe if (mediaAttributes.contains(6)) return@subscribe - messageContent.content = ProtoEditor(messageContent.content).apply { + messageContent.content = ProtoEditor(messageContent.content!!).apply { edit(11, 5, 2) { remove(8) addBuffer(6, byteArrayOf()) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt index 776e55134..60e69aa91 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt @@ -101,14 +101,14 @@ class MessageLogger : Feature("MessageLogger", val messageInstance = event.message.instanceNonNull() if (event.message.messageState != MessageState.COMMITTED) return@subscribe - cachedIdLinks[event.message.messageDescriptor.messageId] = event.message.orderKey - val conversationId = event.message.messageDescriptor.conversationId.toString() + cachedIdLinks[event.message.messageDescriptor!!.messageId!!] = event.message.orderKey!! + val conversationId = event.message.messageDescriptor!!.conversationId.toString() //exclude messages sent by me if (event.message.senderId.toString() == context.database.myUserId) return@subscribe - val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, event.message.orderKey) + val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, event.message.orderKey!!) - if (event.message.messageContent.contentType != ContentType.STATUS) { + if (event.message.messageContent!!.contentType != ContentType.STATUS) { if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe fetchedMessages.add(uniqueMessageIdentifier) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt index f0503f585..d85d09be8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/CoreMessagingBridge.kt @@ -13,13 +13,13 @@ import me.rhunk.snapenhance.core.features.impl.messaging.Messaging fun me.rhunk.snapenhance.core.wrapper.impl.Message.toBridge(): Message { return Message().also { output -> - output.conversationId = this.messageDescriptor.conversationId.toString() + output.conversationId = this.messageDescriptor!!.conversationId.toString() output.senderId = this.senderId.toString() - output.clientMessageId = this.messageDescriptor.messageId - output.serverMessageId = this.orderKey - output.contentType = this.messageContent.contentType?.id ?: -1 - output.content = this.messageContent.content - output.mediaReferences = MessageDecoder.getEncodedMediaReferences(this.messageContent) + output.clientMessageId = this.messageDescriptor!!.messageId!! + output.serverMessageId = this.orderKey!! + output.contentType = this.messageContent?.contentType?.id ?: -1 + output.content = this.messageContent?.content + output.mediaReferences = MessageDecoder.getEncodedMediaReferences(this.messageContent!!) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt index 26ccc3956..7a09a8fc4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt @@ -74,8 +74,8 @@ class MessageExporter( } private fun serializeMessageContent(message: Message): String? { - return if (message.messageContent.contentType == ContentType.CHAT) { - ProtoReader(message.messageContent.content).getString(2, 1) ?: "Failed to parse message" + return if (message.messageContent!!.contentType == ContentType.CHAT) { + ProtoReader(message.messageContent!!.content!!).getString(2, 1) ?: "Failed to parse message" } else null } @@ -93,8 +93,8 @@ class MessageExporter( val sender = conversationParticipants[message.senderId.toString()] val senderUsername = sender?.usernameForSorting ?: message.senderId.toString() val senderDisplayName = sender?.displayName ?: message.senderId.toString() - val messageContent = serializeMessageContent(message) ?: message.messageContent.contentType?.name - val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(message.messageMetadata.createdAt)) + val messageContent = serializeMessageContent(message) ?: message.messageContent!!.contentType?.name + val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(message.messageMetadata!!.createdAt!!)) writer.write("[$date] - $senderDisplayName (${senderUsername}): $messageContent\n") } writer.flush() @@ -110,17 +110,17 @@ class MessageExporter( fun updateProgress(type: String) { val total = messages.filter { - mediaToDownload?.contains(it.messageContent.contentType) ?: false + mediaToDownload?.contains(it.messageContent!!.contentType) ?: false }.size processCount++ printLog("$type $processCount/$total") } messages.filter { - mediaToDownload?.contains(it.messageContent.contentType) ?: false + mediaToDownload?.contains(it.messageContent!!.contentType) ?: false }.forEach { message -> threadPool.execute { - MessageDecoder.decode(message.messageContent).forEach decode@{ attachment -> + MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment -> val protoMediaReference = Base64.UrlSafe.decode(attachment.mediaUrlKey ?: return@decode) runCatching { @@ -145,8 +145,8 @@ class MessageExporter( updateProgress("downloaded") }.onFailure { - printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}") - context.log.error("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}", it) + printLog("failed to download media for ${message.messageDescriptor!!.conversationId}_${message.orderKey}") + context.log.error("failed to download media for ${message.messageDescriptor!!.conversationId}_${message.orderKey}", it) } } } @@ -270,7 +270,7 @@ class MessageExporter( add(JsonObject().apply { addProperty("orderKey", message.orderKey) addProperty("senderId", participants.getOrDefault(message.senderId.toString(), -1)) - addProperty("type", message.messageContent.contentType.toString()) + addProperty("type", message.messageContent!!.contentType.toString()) fun addUUIDList(name: String, list: List) { add(name, JsonArray().apply { @@ -278,12 +278,12 @@ class MessageExporter( }) } - addUUIDList("savedBy", message.messageMetadata.savedBy) - addUUIDList("seenBy", message.messageMetadata.seenBy) - addUUIDList("openedBy", message.messageMetadata.openedBy) + addUUIDList("savedBy", message.messageMetadata!!.savedBy!!) + addUUIDList("seenBy", message.messageMetadata!!.seenBy!!) + addUUIDList("openedBy", message.messageMetadata!!.openedBy!!) add("reactions", JsonObject().apply { - message.messageMetadata.reactions.forEach { reaction -> + message.messageMetadata!!.reactions!!.forEach { reaction -> addProperty( participants.getOrDefault(reaction.userId.toString(), -1L).toString(), reaction.reactionId @@ -291,13 +291,13 @@ class MessageExporter( } }) - addProperty("createdTimestamp", message.messageMetadata.createdAt) - addProperty("readTimestamp", message.messageMetadata.readAt) + addProperty("createdTimestamp", message.messageMetadata!!.createdAt) + addProperty("readTimestamp", message.messageMetadata!!.readAt) addProperty("serializedContent", serializeMessageContent(message)) - addProperty("rawContent", Base64.UrlSafe.encode(message.messageContent.content)) + addProperty("rawContent", Base64.UrlSafe.encode(message.messageContent!!.content!!)) add("attachments", JsonArray().apply { - MessageDecoder.decode(message.messageContent) + MessageDecoder.decode(message.messageContent!!) .forEach attachments@{ attachments -> if (attachments.type == AttachmentType.STICKER) //TODO: implement stickers return@attachments diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt index 493ac91ef..65e2698a4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageSender.kt @@ -94,12 +94,12 @@ class MessageSender( val localMessageContent = context.gson.fromJson(localMessageContentTemplate, context.classCache.localMessageContent) val messageDestinations = MessageDestinations(AbstractWrapper.newEmptyInstance(context.classCache.messageDestinations)).also { - it.conversations = conversations - it.mPhoneNumbers = arrayListOf() - it.stories = arrayListOf() + it.conversations = conversations.toCollection(ArrayList()) + it.mPhoneNumbers = arrayListOf() + it.stories = arrayListOf() } - sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager, messageDestinations.instanceNonNull(), localMessageContent, callback) + sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager?.instanceNonNull(), messageDestinations.instanceNonNull(), localMessageContent, callback) } fun sendChatMessage(conversations: List, message: String, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt index 1adbcdfb8..045ff971a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt @@ -2,24 +2,47 @@ package me.rhunk.snapenhance.core.wrapper import de.robv.android.xposed.XposedHelpers import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID import kotlin.reflect.KProperty abstract class AbstractWrapper( protected var instance: Any? ) { + protected val uuidArrayListMapper: (Any?) -> ArrayList get() = { (it as ArrayList<*>).map { i -> SnapUUID(i) }.toCollection(ArrayList()) } + @Suppress("UNCHECKED_CAST") inner class EnumAccessor(private val fieldName: String, private val defaultValue: T) { operator fun getValue(obj: Any, property: KProperty<*>): T? = getEnumValue(fieldName, defaultValue as Enum<*>) as? T operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) = setEnumValue(fieldName, value as Enum<*>) } + inner class FieldAccessor(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) { + @Suppress("UNCHECKED_CAST") + operator fun getValue(obj: Any, property: KProperty<*>): T? { + val value = XposedHelpers.getObjectField(instance, fieldName) + return if (mapper != null) { + mapper.invoke(value) + } else { + value as? T + } + } + + operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) { + XposedHelpers.setObjectField(instance, fieldName, when (value) { + is AbstractWrapper -> value.instance + is ArrayList<*> -> value.map { if (it is AbstractWrapper) it.instance else it }.toMutableList() + else -> value + }) + } + } + companion object { fun newEmptyInstance(clazz: Class<*>): Any { return CallbackBuilder.createEmptyObject(clazz.constructors[0]) ?: throw NullPointerException() } } - fun instanceNonNull(): Any = instance!! + fun instanceNonNull(): Any = instance ?: throw NullPointerException("Instance of ${this::class.simpleName} is null") fun isPresent(): Boolean = instance != null override fun hashCode(): Int { @@ -31,6 +54,7 @@ abstract class AbstractWrapper( } protected fun enum(fieldName: String, defaultValue: T) = EnumAccessor(fieldName, defaultValue) + protected fun field(fieldName: String, mapper: ((Any?) -> T?)? = null) = FieldAccessor(fieldName, mapper) fun > getEnumValue(fieldName: String, defaultValue: T?): T? { if (defaultValue == null) return null diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt index 08fb84d30..e6d285d41 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -7,7 +7,10 @@ import me.rhunk.snapenhance.core.wrapper.AbstractWrapper typealias CallbackResult = (error: String?) -> Unit -class ConversationManager(val context: ModContext, obj: Any) : AbstractWrapper(obj) { +class ConversationManager( + val context: ModContext, + obj: Any +) : AbstractWrapper(obj) { private fun findMethodByName(name: String) = context.classCache.conversationManager.declaredMethods.find { it.name == name } ?: throw RuntimeException("Could not find method $name") private val updateMessageMethod by lazy { findMethodByName("updateMessage") } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/FriendActionButton.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/FriendActionButton.kt deleted file mode 100644 index 1ac8d3532..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/FriendActionButton.kt +++ /dev/null @@ -1,38 +0,0 @@ -package me.rhunk.snapenhance.core.wrapper.impl - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.View -import me.rhunk.snapenhance.core.SnapEnhance -import me.rhunk.snapenhance.core.wrapper.AbstractWrapper - -class FriendActionButton( - obj: View -) : AbstractWrapper(obj) { - private val iconDrawableContainer by lazy { - instanceNonNull().javaClass.declaredFields.first { it.type != Int::class.javaPrimitiveType }[instanceNonNull()] - } - - private val setIconDrawableMethod by lazy { - iconDrawableContainer.javaClass.declaredMethods.first { - it.parameterTypes.size == 1 && - it.parameterTypes[0] == Drawable::class.java && - it.name != "invalidateDrawable" && - it.returnType == Void::class.javaPrimitiveType - } - } - - fun setIconDrawable(drawable: Drawable) { - setIconDrawableMethod.invoke(iconDrawableContainer, drawable) - } - - companion object { - fun new(context: Context): FriendActionButton { - val instance = SnapEnhance.classLoader.loadClass("com.snap.profile.shared.view.FriendActionButton") - .getConstructor(Context::class.java, AttributeSet::class.java) - .newInstance(context, null) as View - return FriendActionButton(instance) - } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt index b3a03a417..fdf47d38e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt @@ -1,14 +1,21 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.common.data.MessageState -import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class Message(obj: Any?) : AbstractWrapper(obj) { - val orderKey get() = instanceNonNull().getObjectField("mOrderKey") as Long - val senderId get() = SnapUUID(instanceNonNull().getObjectField("mSenderId")) - val messageContent get() = MessageContent(instanceNonNull().getObjectField("mMessageContent")) - val messageDescriptor get() = MessageDescriptor(instanceNonNull().getObjectField("mDescriptor")) - val messageMetadata get() = MessageMetadata(instanceNonNull().getObjectField("mMetadata")) + @get:JSGetter @set:JSSetter + var orderKey by field("mOrderKey") + @get:JSGetter @set:JSSetter + var senderId by field("mSenderId") { SnapUUID(it) } + @get:JSGetter @set:JSSetter + var messageContent by field("mMessageContent") { MessageContent(it) } + @get:JSGetter @set:JSSetter + var messageDescriptor by field("mDescriptor") { MessageDescriptor(it) } + @get:JSGetter @set:JSSetter + var messageMetadata by field("mMetadata") { MessageMetadata(it) } + @get:JSGetter @set:JSSetter var messageState by enum("mState", MessageState.COMMITTED) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt index 851145d15..b229aaef8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageContent.kt @@ -1,15 +1,15 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.common.data.ContentType -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class MessageContent(obj: Any?) : AbstractWrapper(obj) { - var content - get() = instanceNonNull().getObjectField("mContent") as ByteArray - set(value) = instanceNonNull().setObjectField("mContent", value) - val quotedMessage - get() = QuotedMessage(instanceNonNull().getObjectField("mQuotedMessage")) + @get:JSGetter @set:JSSetter + var content by field("mContent") + @get:JSGetter @set:JSSetter + var quotedMessage by field("mQuotedMessage") { QuotedMessage(it) } + @get:JSGetter @set:JSSetter var contentType by enum("mContentType", ContentType.UNKNOWN) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt index e6b5b6eef..d1f04341e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDescriptor.kt @@ -1,9 +1,11 @@ package me.rhunk.snapenhance.core.wrapper.impl -import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { - val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long - val conversationId: SnapUUID get() = SnapUUID(instanceNonNull().getObjectField("mConversationId")!!) + @get:JSGetter @set:JSSetter + var messageId by field("mMessageId") + val conversationId by field("mConversationId") { SnapUUID(it) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt index e3041531e..ea45bf7fd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageDestinations.kt @@ -1,15 +1,10 @@ package me.rhunk.snapenhance.core.wrapper.impl -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper @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())) - var stories get() = instanceNonNull().getObjectField("mStories") as ArrayList - set(value) = instanceNonNull().setObjectField("mStories", value) - var mPhoneNumbers get() = instanceNonNull().getObjectField("mPhoneNumbers") as ArrayList - set(value) = instanceNonNull().setObjectField("mPhoneNumbers", value) + var conversations by field("mConversations", uuidArrayListMapper) + var stories by field>("mStories") + var mPhoneNumbers by field>("mPhoneNumbers") } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt index 39b435291..443b75587 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/MessageMetadata.kt @@ -1,28 +1,26 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.common.data.PlayableSnapState -import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ - val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long - val readAt: Long get() = instanceNonNull().getObjectField("mReadAt") as Long + @get:JSGetter @set:JSSetter + var createdAt by field("mCreatedAt") + @get:JSGetter @set:JSSetter + var readAt by field("mReadAt") + @get:JSGetter @set:JSSetter var playableSnapState by enum("mPlayableSnapState", PlayableSnapState.PLAYABLE) - private fun getUUIDList(name: String): List { - return (instanceNonNull().getObjectField(name) as List<*>).map { SnapUUID(it!!) } - } - - val savedBy: List by lazy { - getUUIDList("mSavedBy") - } - val openedBy: List by lazy { - getUUIDList("mOpenedBy") - } - val seenBy: List by lazy { - getUUIDList("mSeenBy") - } - val reactions: List by lazy { - (instanceNonNull().getObjectField("mReactions") as List<*>).map { UserIdToReaction(it!!) } + @get:JSGetter @set:JSSetter + var savedBy by field("mSavedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var openedBy by field("mOpenedBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var seenBy by field("mSeenBy", uuidArrayListMapper) + @get:JSGetter @set:JSSetter + var reactions by field("mReactions") { + (it as ArrayList<*>).map { i -> UserIdToReaction(i) }.toMutableList() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt index c0868baa8..68ddc916b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessage.kt @@ -1,8 +1,10 @@ package me.rhunk.snapenhance.core.wrapper.impl -import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class QuotedMessage(obj: Any?) : AbstractWrapper(obj) { - val content get() = QuotedMessageContent(instanceNonNull().getObjectField("mContent")) + @get:JSGetter @set:JSSetter + var content by field("mContent") { QuotedMessageContent(it) } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt index 137fbea09..b89ede169 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/QuotedMessageContent.kt @@ -1,10 +1,10 @@ package me.rhunk.snapenhance.core.wrapper.impl -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class QuotedMessageContent(obj: Any?) : AbstractWrapper(obj) { - var messageId get() = instanceNonNull().getObjectField("mMessageId") as Long - set(value) = instanceNonNull().setObjectField("mMessageId", value) + @get:JSGetter @set:JSSetter + var messageId by field("mMessageId") } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt index 6869a41b6..1bf67f511 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/UserIdToReaction.kt @@ -1,11 +1,21 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { - val userId = SnapUUID(instanceNonNull().getObjectField("mUserId")) - val reactionId = (instanceNonNull().getObjectField("mReaction") + @get:JSGetter @set:JSSetter + var userId by field("mUserId") { SnapUUID(it) } + @get:JSGetter @set:JSSetter + var reactionId get() = (instanceNonNull().getObjectField("mReaction") ?.getObjectField("mReactionContent") - ?.getObjectField("mIntentionType") as Long?) ?: 0 + ?.getObjectField("mIntentionType") as Long?) ?: -1 + set(value) { + instanceNonNull().getObjectField("mReaction") + ?.getObjectField("mReactionContent") + ?.setObjectField("mIntentionType", value) + } } \ No newline at end of file From f1a368d44e9cd1c9714d0e0ccaf9d38ac2768ae1 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 3 Nov 2023 04:11:46 +0100 Subject: [PATCH 200/274] feat: LSPatch obfuscation --- .../me/rhunk/snapenhance/core/SnapEnhance.kt | 4 + .../impl/experiments/DeviceSpooferHook.kt | 4 +- .../snapenhance/core/util/LSPatchUpdater.kt | 13 +++ manager/build.gradle.kts | 1 + .../snapenhance/manager/data/SharedConfig.kt | 3 + .../snapenhance/manager/lspatch/LSPatch.kt | 86 ++++++++++---- .../manager/lspatch/LSPatchObfuscation.kt | 107 ++++++++++++++++++ .../manager/lspatch/config/Constants.kt | 1 - .../manager/ui/tab/impl/SettingsTab.kt | 5 + .../ui/tab/impl/download/LSPatchTab.kt | 9 +- 10 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt index 7d2c0cfcf..6837bb1b3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -64,6 +64,10 @@ class SnapEnhance { } runCatching { LSPatchUpdater.onBridgeConnected(appContext, bridgeClient) + }.onFailure { + logCritical("Failed to init LSPatchUpdater", it) + } + runCatching { measureTimeMillis { runBlocking { init(this) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt index 11ef7536b..302f20071 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DeviceSpooferHook.kt @@ -5,8 +5,8 @@ import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker -class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { +class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { if (context.config.experimental.spoof.globalState != true) return val fingerprint by context.config.experimental.spoof.device.fingerprint diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt index 54146e818..24a6b03ec 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt @@ -18,11 +18,24 @@ object LSPatchUpdater { } fun onBridgeConnected(context: ModContext, bridgeClient: BridgeClient) { + val obfuscatedModulePath by lazy { + (runCatching { + context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants") + }.getOrNull())?.declaredFields?.firstOrNull { it.name == "MANAGER_PACKAGE_NAME" }?.also { + it.isAccessible = true + }?.get(null) as? String + } + val embeddedModule = context.androidContext.cacheDir .resolve("lspatch") .resolve(BuildConfig.APPLICATION_ID).let { moduleDir -> if (!moduleDir.exists()) return@let null moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: obfuscatedModulePath?.let { path -> + context.androidContext.cacheDir.resolve(path).let dir@{ moduleDir -> + if (!moduleDir.exists()) return@dir null + moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } + } ?: return } ?: return context.log.verbose("Found embedded SE at ${embeddedModule.absolutePath}", TAG) diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 60ee6a7f0..cf4481dc6 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.libsu) implementation(libs.guava) implementation(libs.apksig) + implementation(libs.dexlib2) implementation(libs.gson) implementation(libs.jsoup) implementation(libs.okhttp) diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt index 53541aaf4..815b37a44 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -21,4 +21,7 @@ class SharedConfig( var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() + + var obfuscateLSPatch get() = sharedPreferences.getBoolean("obfuscateLSPatch", false) + set(value) = sharedPreferences.edit().putBoolean("obfuscateLSPatch", value).apply() } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt index 4b052a4e6..903cdf7ad 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt @@ -10,7 +10,6 @@ import com.google.gson.Gson import com.wind.meditor.core.ManifestEditor import com.wind.meditor.property.AttributeItem import com.wind.meditor.property.ModificationProperty -import me.rhunk.snapenhance.manager.lspatch.config.Constants.ORIGINAL_APK_ASSET_PATH import me.rhunk.snapenhance.manager.lspatch.config.Constants.PROXY_APP_COMPONENT_FACTORY import me.rhunk.snapenhance.manager.lspatch.config.PatchConfig import me.rhunk.snapenhance.manager.lspatch.util.ApkSignatureHelper @@ -22,28 +21,22 @@ import java.security.cert.X509Certificate import java.util.zip.ZipFile import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.random.Random //https://github.com/LSPosed/LSPatch/blob/master/patch/src/main/java/org/lsposed/patch/LSPatch.java class LSPatch( private val context: Context, private val modules: Map, //packageName -> file + private val obfuscate: Boolean, private val printLog: (Any) -> Unit ) { - companion object { - private val Z_FILE_OPTIONS = ZFileOptions().setAlignmentRule( - AlignmentRules.compose( - AlignmentRules.constantForSuffix(".so", 4096), - AlignmentRules.constantForSuffix(ORIGINAL_APK_ASSET_PATH, 4096) - ) - ) - } - private fun patchManifest(data: ByteArray, lspatchMetadata: String): ByteArray { + private fun patchManifest(data: ByteArray, lspatchMetadata: Pair): ByteArray { val property = ModificationProperty() property.addApplicationAttribute(AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)) - property.addMetaData(ModificationProperty.MetaData("lspatch", lspatchMetadata)) + property.addMetaData(ModificationProperty.MetaData(lspatchMetadata.first, lspatchMetadata.second)) return ByteArrayOutputStream().apply { ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() @@ -70,7 +63,7 @@ class LSPatch( private fun resignApk(inputApkFile: File, outputFile: File) { printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") - val dstZFile = ZFile.openReadWrite(outputFile, Z_FILE_OPTIONS) + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) val inZFile = ZFile.openReadOnly(inputApkFile) inZFile.entries().forEach { entry -> @@ -90,12 +83,42 @@ class LSPatch( printLog("Done") } + private fun uniqueHash(): String { + return Random.nextBytes(Random.nextInt(5, 10)).joinToString("") { "%02x".format(it) } + } + @Suppress("UNCHECKED_CAST") @OptIn(ExperimentalEncodingApi::class) private fun patchApk(inputApkFile: File, outputFile: File) { printLog("Patching ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") - val dstZFile = ZFile.openReadWrite(outputFile, Z_FILE_OPTIONS) - val sourceApkFile = dstZFile.addNestedZip({ ORIGINAL_APK_ASSET_PATH }, inputApkFile, false) + + val obfuscationCacheFolder = File(context.cacheDir, "lspatch").apply { + if (exists()) deleteRecursively() + mkdirs() + } + val lspatchObfuscation = LSPatchObfuscation(obfuscationCacheFolder) { printLog(it) } + val dexObfuscationConfig = if (obfuscate) DexObfuscationConfig( + packageName = uniqueHash(), + metadataManifestField = uniqueHash(), + metaLoaderFilePath = uniqueHash(), + configFilePath = uniqueHash(), + loaderFilePath = uniqueHash(), + libNativeFilePath = mapOf( + "arm64-v8a" to uniqueHash() + ".so", + "armeabi-v7a" to uniqueHash() + ".so", + ), + originApkPath = uniqueHash(), + cachedOriginApkPath = uniqueHash(), + openAtApkPath = uniqueHash(), + assetModuleFolderPath = uniqueHash(), + ) else null + + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose( + AlignmentRules.constantForSuffix(".so", 4096), + AlignmentRules.constantForSuffix("assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk"), 4096) + ) + )) val patchConfig = PatchConfig( useManager = false, @@ -115,32 +138,37 @@ class LSPatch( printLog("Patching manifest") + val sourceApkFile = dstZFile.addNestedZip({ "assets/" + (dexObfuscationConfig?.originApkPath ?: "lspatch/origin.apk") }, inputApkFile, false) val originalManifestEntry = sourceApkFile.get("AndroidManifest.xml") ?: throw Exception("No original manifest found") originalManifestEntry.open().use { inputStream -> - val patchedManifestData = patchManifest(inputStream.readBytes(), Base64.encode(patchConfig.toByteArray())) + val patchedManifestData = patchManifest(inputStream.readBytes(), (dexObfuscationConfig?.metadataManifestField ?: "lspatch") to Base64.encode(patchConfig.toByteArray())) dstZFile.add("AndroidManifest.xml", patchedManifestData.inputStream()) } //add config printLog("Adding config") - dstZFile.add("assets/lspatch/config.json", ByteArrayInputStream(patchConfig.toByteArray())) + dstZFile.add("assets/" + (dexObfuscationConfig?.configFilePath ?: "lspatch/config.json"), ByteArrayInputStream(patchConfig.toByteArray())) // add loader dex - printLog("Adding dex files") - dstZFile.add("classes.dex", context.assets.open("lspatch/dexes/metaloader.dex")) - dstZFile.add("assets/lspatch/loader.dex", context.assets.open("lspatch/dexes/loader.dex")) + printLog("Adding loader dex") + context.assets.open("lspatch/dexes/loader.dex").use { inputStream -> + dstZFile.add("assets/" + (dexObfuscationConfig?.loaderFilePath ?: "lspatch/loader.dex"), dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateLoader(inputStream, it).inputStream() + } ?: inputStream) + } //add natives printLog("Adding natives") context.assets.list("lspatch/so")?.forEach { native -> - dstZFile.add("assets/lspatch/so/$native/liblspatch.so", context.assets.open("lspatch/so/$native/liblspatch.so"), false) + dstZFile.add("assets/${dexObfuscationConfig?.libNativeFilePath?.get(native) ?: "lspatch/so/$native/liblspatch.so"}", context.assets.open("lspatch/so/$native/liblspatch.so"), false) } //embed modules printLog("Embedding modules") modules.forEach { (packageName, module) -> - printLog("- $packageName") - dstZFile.add("assets/lspatch/modules/$packageName.apk", module.inputStream()) + val obfuscatedPackageName = dexObfuscationConfig?.packageName ?: packageName + printLog("- $obfuscatedPackageName") + dstZFile.add("assets/${dexObfuscationConfig?.assetModuleFolderPath ?: "lspatch/modules"}/$obfuscatedPackageName.apk", module.inputStream()) } // link apk entries @@ -148,7 +176,7 @@ class LSPatch( for (entry in sourceApkFile.entries()) { val name = entry.centralDirectoryHeader.name - if (name.startsWith("classes") && name.endsWith(".dex")) continue + if (dexObfuscationConfig == null && name.startsWith("classes") && name.endsWith(".dex")) continue if (dstZFile[name] != null) continue if (name == "AndroidManifest.xml") continue if (name.startsWith("META-INF") && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith( @@ -158,8 +186,20 @@ class LSPatch( sourceApkFile.addFileLink(name, name) } + printLog("Adding meta loader dex") + context.assets.open("lspatch/dexes/metaloader.dex").use { inputStream -> + dstZFile.add(dexObfuscationConfig?.let { "classes9.dex" } ?: "classes.dex", dexObfuscationConfig?.let { + lspatchObfuscation.obfuscateMetaLoader(inputStream, it).inputStream() + } ?: inputStream) + } + + printLog("Writing apk") dstZFile.realign() dstZFile.close() + sourceApkFile.close() + + printLog("Cleaning obfuscation cache") + obfuscationCacheFolder.deleteRecursively() printLog("Done") } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt new file mode 100644 index 000000000..d478e2c8c --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt @@ -0,0 +1,107 @@ +package me.rhunk.snapenhance.manager.lspatch + +import org.jf.dexlib2.Opcodes +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.reference.StringReference +import org.jf.dexlib2.writer.io.FileDataStore +import org.jf.dexlib2.writer.pool.DexPool +import org.jf.dexlib2.writer.pool.StringPool +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream + +data class DexObfuscationConfig( + val packageName: String, + val metadataManifestField: String? = null, + val metaLoaderFilePath: String? = null, + val configFilePath: String? = null, + val loaderFilePath: String? = null, + val originApkPath: String? = null, + val cachedOriginApkPath: String? = null, + val openAtApkPath: String? = null, + val assetModuleFolderPath: String? = null, + val libNativeFilePath: Map = mapOf(), +) + +class LSPatchObfuscation( + private val cacheFolder: File, + private val printLog: (String) -> Unit = { println(it) } +) { + private fun obfuscateDexFile(dexStrings: Map, inputStream: InputStream): File { + val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(inputStream)) + + val dexPool = object: DexPool(dexFile.opcodes) { + override fun getSectionProvider(): SectionProvider { + val dexPool = this + return object: DexPoolSectionProvider() { + override fun getStringSection() = object: StringPool(dexPool) { + private val cacheMap = mutableMapOf() + + override fun intern(string: CharSequence) { + dexStrings[string.toString()]?.let { + cacheMap[string.toString()] = it + printLog("mapping $string to $it") + super.intern(it) + return + } + super.intern(string) + } + + override fun getItemIndex(key: CharSequence): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + + override fun getItemIndex(key: StringReference): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + } + } + } + } + dexFile.classes.forEach { dexBackedClassDef -> + dexPool.internClass(dexBackedClassDef) + } + val outputFile = File.createTempFile("obf", ".dex", cacheFolder) + dexPool.writeTo(FileDataStore(outputFile)) + return outputFile + } + + + fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return obfuscateDexFile(mapOf( + "assets/lspatch/config.json" to "assets/${config.configFilePath}", + "assets/lspatch/loader.dex" to "assets/${config.loaderFilePath}", + ) + (config.libNativeFilePath.takeIf { it.isNotEmpty() }?.let { + mapOf( + "!/assets/lspatch/so/" to "!/assets/", + "assets/lspatch/so/" to "assets/", + "/liblspatch.so" to "", + "arm64-v8a" to config.libNativeFilePath["arm64-v8a"], + "armeabi-v7a" to config.libNativeFilePath["armeabi-v7a"], + "x86" to config.libNativeFilePath["x86"], + "x86_64" to config.libNativeFilePath["x86_64"], + ) + } ?: mapOf()), inputStream) + } + + fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File { + return obfuscateDexFile(mapOf( + "assets/lspatch/config.json" to config.configFilePath?.let { "assets/$it" }, + "assets/lspatch/loader.dex" to config.loaderFilePath?.let { "assets/$it" }, + "assets/lspatch/metaloader.dex" to config.metaLoaderFilePath?.let { "assets/$it" }, + "assets/lspatch/origin.apk" to config.originApkPath?.let { "assets/$it" }, + "/lspatch/origin/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + ==> "/lspatch/origin/" <== + sourceFile.getEntry(ORIGINAL_APK_ASSET_PATH).getCrc() + ".apk"; + "/lspatch/" to config.cachedOriginApkPath?.let { "/$it/" }, // context.getCacheDir() + "/lspatch/" + packageName + "/" + "cache/lspatch/origin/" to config.cachedOriginApkPath?.let { "cache/$it" }, //LSPApplication => Path originPath = Paths.get(appInfo.dataDir, "cache/lspatch/origin/"); + "assets/lspatch/modules/" to config.assetModuleFolderPath?.let { "assets/$it/" }, // Constants.java => EMBEDDED_MODULES_ASSET_PATH + "lspatch/modules" to config.assetModuleFolderPath, // LocalApplicationService.java => context.getAssets().list("lspatch/modules"), + "lspatch/modules/" to config.assetModuleFolderPath?.let { "$it/" }, // LocalApplicationService.java => try (var is = context.getAssets().open("lspatch/modules/" + name)) { + "lspatch" to config.metadataManifestField, // SigBypass.java => "lspatch", + "org.lsposed.lspatch" to config.cachedOriginApkPath?.let { "$it/${config.packageName}/" }, // Constants.java => "org.lsposed.lspatch", (Used in LSPatchUpdater.kt) + ), inputStream) + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt index 0af8c9de4..adcd943ae 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt @@ -2,7 +2,6 @@ package me.rhunk.snapenhance.manager.lspatch.config //https://github.com/LSPosed/LSPatch/blob/master/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java object Constants { - const val ORIGINAL_APK_ASSET_PATH = "assets/lspatch/origin.apk" const val PROXY_APP_COMPONENT_FACTORY = "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub" } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt index 8db18b7eb..30badeb17 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt @@ -145,6 +145,11 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti setValue = { sharedConfig.useRootInstaller = it }, label = "Use root installer" ) + ConfigBooleanRow( + getValue = { sharedConfig.obfuscateLSPatch }, + setValue = { sharedConfig.obfuscateLSPatch = it }, + label = "Obfuscate LSPatch (experimental)" + ) } } } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt index d5e359339..3d26eff71 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem @@ -85,7 +86,7 @@ class LSPatchTab : Tab("lspatch") { sharedConfig.snapEnhancePackageName to module, ), printLog = { log("[LSPatch] $it") - }) + }, obfuscate = sharedConfig.obfuscateLSPatch) log("== Patching apk ==") val outputFiles = lsPatch.patchSplits(listOf(apkFile!!)) @@ -138,6 +139,12 @@ class LSPatchTab : Tab("lspatch") { } } + DisposableEffect(Unit) { + onDispose { + coroutineScope.cancel() + } + } + val scrollState = rememberScrollState() fun triggerInstallation(shouldUninstall: Boolean) { From 517aa82157a868b44c19257a68f737bbbcb7ad44 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 5 Nov 2023 02:32:56 +0100 Subject: [PATCH 201/274] fix(core): notifications --- .../features/impl/messaging/Notifications.kt | 350 ++++++++++-------- .../snapenhance/core/util/CallbackBuilder.kt | 19 +- .../snapenhance/core/util/hook/HookAdapter.kt | 2 +- .../core/wrapper/impl/ConversationManager.kt | 12 +- 4 files changed, 208 insertions(+), 175 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index 43af54b9d..b57062a57 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -12,6 +12,10 @@ import android.os.Bundle import android.os.UserHandle import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.MediaReferenceType import me.rhunk.snapenhance.common.data.MessageUpdate @@ -33,8 +37,25 @@ import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.util.media.PreviewUtils import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import kotlin.coroutines.suspendCoroutine class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { + inner class NotificationData( + val tag: String?, + val id: Int, + var notification: Notification, + val userHandle: UserHandle + ) { + fun send() { + XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( + tag, id, notification, userHandle + )) + } + + fun copy(tag: String? = this.tag, id: Int = this.id, notification: Notification = this.notification, userHandle: UserHandle = this.userHandle) = + NotificationData(tag, id, notification, userHandle) + } + companion object{ const val ACTION_REPLY = "me.rhunk.snapenhance.action.notification.REPLY" const val ACTION_DOWNLOAD = "me.rhunk.snapenhance.action.notification.DOWNLOAD" @@ -42,9 +63,10 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN const val SNAPCHAT_NOTIFICATION_GROUP = "snapchat_notification_group" } - private val notificationDataQueue = mutableMapOf() // messageId => notification - private val cachedMessages = mutableMapOf>() // conversationId => cached messages - private val notificationIdMap = mutableMapOf() // notificationId => conversationId + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val cachedMessages = mutableMapOf>() // conversationId => orderKey, message + private val sentNotifications = mutableMapOf() // notificationId => conversationId private val notifyAsUserMethod by lazy { XposedHelpers.findMethodExact( @@ -56,10 +78,6 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN ) } - private val fetchConversationWithMessagesMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"} - } - private val notificationManager by lazy { context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } @@ -70,11 +88,17 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN context.config.messaging.betterNotifications.get() } + private fun newNotificationBuilder(notification: Notification) = XposedHelpers.newInstance( + Notification.Builder::class.java, + context.androidContext, + notification + ) as Notification.Builder + private fun setNotificationText(notification: Notification, conversationId: String) { val messageText = StringBuilder().apply { - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { + cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }.forEach { if (isNotEmpty()) append("\n") - append(it) + append(it.value) } }.toString() @@ -91,14 +115,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } - private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, messageId: Long, notificationData: NotificationData) { - - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - + private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, message: Message, notificationData: NotificationData) { val actions = mutableListOf() actions.addAll(notificationData.notification.actions) @@ -108,7 +125,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { putExtra("conversation_id", conversationId) putExtra("notification_id", notificationData.id) - putExtra("message_id", messageId) + putExtra("client_message_id", message.messageDescriptor!!.messageId!!) } val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( @@ -137,7 +154,9 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN betterNotificationFilter.contains("mark_as_read_button") }) {} - notificationBuilder.setActions(*actions.toTypedArray()) + val notificationBuilder = newNotificationBuilder(notificationData.notification).apply { + setActions(*actions.toTypedArray()) + } notificationData.notification = notificationBuilder.build() } @@ -145,15 +164,26 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> val intent = event.intent ?: return@subscribe val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe - val messageId = intent.getLongExtra("message_id", -1) + val clientMessageId = intent.getLongExtra("client_message_id", -1) val notificationId = intent.getIntExtra("notification_id", -1) 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 - )) + NotificationData(it.tag, it.id, it.notification, it.user).send() + } + } + + suspend fun appendNotificationText(input: String) { + cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }.let { + it[(it.keys.lastOrNull() ?: 0) + 1L] = input + } + + withContext(Dispatchers.Main) { + updateNotification(notificationId) { notification -> + notification.flags = notification.flags or Notification.FLAG_ONLY_ALERT_ONCE + setNotificationText(notification, conversationId) + } } } @@ -161,23 +191,22 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN ACTION_REPLY -> { val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") .toString() + val myUser = context.database.myUserId.let { context.database.getFriendInfo(it) } ?: return@subscribe - context.database.myUserId.let { context.database.getFriendInfo(it) }?.let { myUser -> - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input") - - 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.coroutineScope.launch(coroutineDispatcher) { + appendNotificationText("Failed to send message: $it") } - - context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = { - context.longToast("Failed to send message: $it") - }) - } + }, onSuccess = { + context.coroutineScope.launch(coroutineDispatcher) { + appendNotificationText("${myUser.displayName ?: myUser.mutableUsername}: $input") + } + }) } ACTION_DOWNLOAD -> { runCatching { - context.feature(MediaDownloader::class).downloadMessageId(messageId, isPreview = false) + context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) }.onFailure { context.longToast(it) } @@ -193,7 +222,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN conversationManager.displayedMessages( conversationId, - messageId, + clientMessageId, onResult = { if (it != null) { context.log.error("Failed to mark conversation as read: $it") @@ -202,10 +231,10 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } ) - val conversationMessage = context.database.getConversationMessageFromId(messageId) ?: return@subscribe + val conversationMessage = context.database.getConversationMessageFromId(clientMessageId) ?: return@subscribe if (conversationMessage.contentType == ContentType.SNAP.id) { - conversationManager.updateMessage(conversationId, messageId, MessageUpdate.READ) { + conversationManager.updateMessage(conversationId, clientMessageId, MessageUpdate.READ) { if (it != null) { context.log.error("Failed to open snap: $it") context.shortToast("Failed to open snap") @@ -225,117 +254,111 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } - private fun fetchMessagesResult(conversationId: String, messages: List) { - val sendNotificationData = { notificationData: NotificationData, forceCreate: Boolean -> - val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id - notificationIdMap.computeIfAbsent(notificationId) { conversationId } - if (betterNotificationFilter.contains("group")) { - runCatching { - notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP) - - val summaryNotification = Notification.Builder(context.androidContext, notificationData.notification.channelId) - .setSmallIcon(notificationData.notification.smallIcon) - .setGroup(SNAPCHAT_NOTIFICATION_GROUP) - .setGroupSummary(true) - .setAutoCancel(true) - .setOnlyAlertOnce(true) - .build() - - if (notificationManager.activeNotifications.firstOrNull { it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 } == null) { - notificationManager.notify(notificationData.tag, notificationData.id, summaryNotification) - } - }.onFailure { - context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", featureKey) + private fun sendNotification(message: Message, notificationData: NotificationData, forceCreate: Boolean) { + val conversationId = message.messageDescriptor?.conversationId.toString() + val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id + sentNotifications.computeIfAbsent(notificationId) { conversationId } + + if (betterNotificationFilter.contains("group")) { + runCatching { + if (notificationManager.activeNotifications.firstOrNull { + it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 + } == null) { + notificationManager.notify( + notificationData.tag, + System.nanoTime().toInt(), + Notification.Builder(context.androidContext, notificationData.notification.channelId) + .setSmallIcon(notificationData.notification.smallIcon) + .setGroup(SNAPCHAT_NOTIFICATION_GROUP) + .setGroupSummary(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .build() + ) } + }.onFailure { + context.log.warn("Failed to set notification group key: ${it.stackTraceToString()}", featureKey) } - - XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( - notificationData.tag, if (forceCreate) System.nanoTime().toInt() else notificationData.id, notificationData.notification, notificationData.userHandle - )) } - 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.mutableUsername - } - } + notificationData.copy(id = notificationId).also { + setupNotificationActionButtons(message.messageContent!!.contentType!!, conversationId, message, it) + }.send() + } - val contentType = snapMessage.messageContent!!.contentType ?: return@onEach - val contentData = snapMessage.messageContent!!.content!! + private fun onMessageReceived(data: NotificationData, message: Message) { + val conversationId = message.messageDescriptor?.conversationId.toString() + val orderKey = message.orderKey ?: return + val senderUsername by lazy { + context.database.getFriendInfo(message.senderId.toString())?.let { + it.displayName ?: it.mutableUsername + } ?: "Unknown" + } - val formatUsername: (String) -> String = { "$senderUsername: $it" } - val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } } - val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, conversationId)} + val formatUsername: (String) -> String = { "$senderUsername: $it" } + val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { sortedMapOf() } } + val appendNotifications: () -> Unit = { setNotificationText(data.notification, conversationId)} - setupNotificationActionButtons(contentType, conversationId, snapMessage.messageDescriptor!!.messageId!!, notificationData) - 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 -> { - val mediaReferences = MessageDecoder.getMediaReferences( - messageContent = context.gson.toJsonTree(snapMessage.messageContent!!.instanceNonNull()) - ) + when (val contentType = message.messageContent!!.contentType) { + ContentType.NOTE -> { + notificationCache[orderKey] = formatUsername("sent audio note") + appendNotifications() + } + ContentType.CHAT -> { + ProtoReader(message.messageContent!!.content!!).getString(2, 1)?.trim()?.let { + notificationCache[orderKey] = formatUsername(it) + } + appendNotifications() + } + ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { + val mediaReferences = MessageDecoder.getMediaReferences( + messageContent = context.gson.toJsonTree(message.messageContent!!.instanceNonNull()) + ) - val mediaReferenceKeys = mediaReferences.map { reference -> - reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() - } + val mediaReferenceKeys = mediaReferences.map { reference -> + reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + } - MessageDecoder.decode(snapMessage.messageContent!!).firstOrNull()?.also { media -> - val mediaType = MediaReferenceType.valueOf(mediaReferences.first().asJsonObject["mMediaType"].asString) + MessageDecoder.decode(message.messageContent!!).firstOrNull()?.also { media -> + val mediaType = MediaReferenceType.valueOf(mediaReferences.first().asJsonObject["mMediaType"].asString) - runCatching { - val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = { - media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it - }) ?: throw Throwable("Unable to download media") + runCatching { + val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = { + media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }) ?: throw Throwable("Unable to download media") - val downloadedMedias = mutableMapOf() + val downloadedMedias = mutableMapOf() - MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream -> - downloadedMedias[type] = inputStream.readBytes() - } + MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream -> + downloadedMedias[type] = inputStream.readBytes() + } - var bitmapPreview = PreviewUtils.createPreview(downloadedMedias[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! + var bitmapPreview = PreviewUtils.createPreview(downloadedMedias[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! - downloadedMedias[SplitMediaAssetType.OVERLAY]?.let { - bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) - } + downloadedMedias[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 { - context.log.error("Failed to send preview notification", it) - } + val notificationBuilder = newNotificationBuilder(data.notification).apply { + setLargeIcon(bitmapPreview) + style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) } - } - else -> { - notificationCache.add(formatUsername("sent ${contentType.name.lowercase()}")) - appendNotifications() + + sendNotification(message, data.copy(notification = notificationBuilder.build()), true) + return + }.onFailure { + context.log.error("Failed to send preview notification", it) } } - - sendNotificationData(notificationData, false) - }.clear() + } + else -> { + notificationCache[orderKey] = formatUsername("sent ${contentType?.name?.lowercase()}") + appendNotifications() + } } + + sendNotification(message, data, false) } override fun init() { @@ -343,39 +366,53 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN notifyAsUserMethod.hook(HookStage.BEFORE) { param -> val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) + val extras = notificationData.notification.extras.getBundle("system_notification_extras")?: return@hook - val extras: Bundle = notificationData.notification.extras.getBundle("system_notification_extras")?: return@hook - - val messageId = extras.getString("message_id") ?: return@hook - val notificationType = extras.getString("notification_type") ?: return@hook - val conversationId = extras.getString("conversation_id") ?: return@hook + if (betterNotificationFilter.contains("group")) { + notificationData.notification.setObjectField("mGroupKey", SNAPCHAT_NOTIFICATION_GROUP) + } - if (betterNotificationFilter.map { it.uppercase() }.none { - notificationType.contains(it) - }) return@hook + val conversationId = extras.getString("conversation_id").also { id -> + sentNotifications.computeIfAbsent(notificationData.id) { id ?: "" } + } ?: return@hook - synchronized(notificationDataQueue) { - notificationDataQueue[messageId.toLong()] = notificationData - } + val serverMessageId = extras.getString("message_id") ?: return@hook + val notificationType = extras.getString("notification_type") ?: return@hook - context.feature(Messaging::class).conversationManager?.fetchConversationWithMessages(conversationId, onSuccess = { messages -> - fetchMessagesResult(conversationId, messages) - }, onError = { - context.log.error("Failed to fetch conversation with messages: $it") - }) + if (betterNotificationFilter.none { notificationType.contains(it, ignoreCase = true) }) return@hook param.setResult(null) + val conversationManager = context.feature(Messaging::class).conversationManager ?: return@hook + + context.coroutineScope.launch(coroutineDispatcher) { + suspendCoroutine { continuation -> + conversationManager.fetchMessageByServerId(conversationId, serverMessageId, onSuccess = { + onMessageReceived(notificationData, it) + continuation.resumeWith(Result.success(Unit)) + }, onError = { + context.log.error("Failed to fetch message id ${serverMessageId}: $it") + continuation.resumeWith(Result.success(Unit)) + }) + } + } } - XposedHelpers.findMethodExact( - NotificationManager::class.java, - "cancelAsUser", String::class.java, - Int::class.javaPrimitiveType, - UserHandle::class.java - ).hook(HookStage.BEFORE) { param -> + NotificationManager::class.java.declaredMethods.find { + it.name == "cancelAsUser" + }?.hook(HookStage.AFTER) { param -> val notificationId = param.arg(1) - notificationIdMap[notificationId]?.let { - cachedMessages[it]?.clear() + + context.coroutineScope.launch(coroutineDispatcher) { + sentNotifications[notificationId]?.let { + cachedMessages[it]?.clear() + } + sentNotifications.remove(notificationId) + } + + notificationManager.activeNotifications.let { notifications -> + if (notifications.all { it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 }) { + notifications.forEach { param.invokeOriginal(arrayOf(it.tag, it.id, it.user)) } + } } } @@ -398,11 +435,4 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } } - - data class NotificationData( - val tag: String?, - val id: Int, - var notification: Notification, - val userHandle: UserHandle - ) } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt index 97fce302b..ad499d5c5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt @@ -73,15 +73,16 @@ class CallbackBuilder( //compute the args for the constructor with null or default primitive values val args = constructor.parameterTypes.map { type: Class<*> -> if (type.isPrimitive) { - when (type.name) { - "boolean" -> return@map false - "byte" -> return@map 0.toByte() - "char" -> return@map 0.toChar() - "short" -> return@map 0.toShort() - "int" -> return@map 0 - "long" -> return@map 0L - "float" -> return@map 0f - "double" -> return@map 0.0 + return@map when (type.name) { + "boolean" -> false + "byte" -> 0.toByte() + "char" -> 0.toChar() + "short" -> 0.toShort() + "int" -> 0 + "long" -> 0L + "float" -> 0f + "double" -> 0.0 + else -> null } } null diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt index a99669593..c29ec52da 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookAdapter.kt @@ -58,7 +58,7 @@ class HookAdapter( return XposedBridge.invokeOriginalMethod(method(), thisObject(), args()) } - fun invokeOriginal(args: Array): Any? { + fun invokeOriginal(args: Array): Any? { return XposedBridge.invokeOriginalMethod(method(), thisObject(), args) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt index e6d285d41..29a664436 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper typealias CallbackResult = (error: String?) -> Unit @@ -63,7 +64,7 @@ class ConversationManager( fun displayedMessages(conversationId: String, messageId: Long, onResult: CallbackResult = {}) { displayedMessagesMethod.invoke( instanceNonNull(), - conversationId.toSnapUUID(), + conversationId.toSnapUUID().instanceNonNull(), messageId, CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) .override("onSuccess") { onResult(null) } @@ -87,16 +88,17 @@ class ConversationManager( } fun fetchMessageByServerId(conversationId: String, serverMessageId: String, onSuccess: (Message) -> Unit, onError: (error: String) -> Unit) { - val serverMessageIdentifier = context.classCache.serverMessageIdentifier - .getConstructor(context.classCache.snapUUID, Long::class.javaPrimitiveType) - .newInstance(conversationId.toSnapUUID().instanceNonNull(), serverMessageId.toLong()) + val serverMessageIdentifier = CallbackBuilder.createEmptyObject(context.classCache.serverMessageIdentifier.constructors.first())?.apply { + setObjectField("mServerConversationId", conversationId.toSnapUUID().instanceNonNull()) + setObjectField("mServerMessageId", serverMessageId.toLong()) + } fetchMessageByServerId.invoke( instanceNonNull(), serverMessageIdentifier, CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchMessageCallback")) .override("onFetchMessageComplete") { param -> - onSuccess(Message(param.arg(1))) + onSuccess(Message(param.arg(0))) } .override("onError") { onError(it.arg(0).toString()) From a160be8fd398ba9bf7b3977d5dd049ed157a835f Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 5 Nov 2023 02:57:04 +0100 Subject: [PATCH 202/274] fix(streaks_reminder): cooldown --- .../snapenhance/messaging/StreaksReminder.kt | 24 +++++++++++-------- .../config/impl/StreaksReminderConfig.kt | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt index b82447db6..0c1ba5e49 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -16,6 +16,9 @@ import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.bridge.ForceStartActivity import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.util.ImageRequestHelper +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes class StreaksReminder( private val remoteSideContext: RemoteSideContext? = null @@ -37,11 +40,21 @@ class StreaksReminder( override fun onReceive(ctx: Context, intent: Intent) { val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx) val streaksReminderConfig = remoteSideContext.config.root.streaksReminder + val sharedPreferences = remoteSideContext.sharedPreferences if (streaksReminderConfig.globalState != true) return + val interval = streaksReminderConfig.interval.get() val remainingHours = streaksReminderConfig.remainingHours.get() + if (sharedPreferences.getLong("lastStreaksReminder", 0).milliseconds + interval.hours - 10.minutes > System.currentTimeMillis().milliseconds) return + sharedPreferences.edit().putLong("lastStreaksReminder", System.currentTimeMillis()).apply() + + remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating( + AlarmManager.RTC_WAKEUP, 5000, interval.toLong() * 60 * 60 * 1000, + PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java), + PendingIntent.FLAG_IMMUTABLE) + ) val notifyFriendList = remoteSideContext.modDatabase.getFriends() .associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) } @@ -102,18 +115,9 @@ class StreaksReminder( } } - //TODO: ask for notifications permission for a13+ fun init() { if (remoteSideContext == null) throw IllegalStateException("RemoteSideContext is null") - val reminderConfig = remoteSideContext.config.root.streaksReminder.also { - if (it.globalState != true) return - } - - remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating( - AlarmManager.RTC_WAKEUP, 5000, reminderConfig.interval.get().toLong() * 60 * 60 * 1000, - PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java), - PendingIntent.FLAG_IMMUTABLE) - ) + if (remoteSideContext.config.root.streaksReminder.globalState != true) return onReceive(remoteSideContext.androidContext, Intent()) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt index 569c72a9a..9c9c0dce1 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/StreaksReminderConfig.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.common.config.impl import me.rhunk.snapenhance.common.config.ConfigContainer class StreaksReminderConfig : ConfigContainer(hasGlobalState = true) { - val interval = integer("interval", 2) + val interval = integer("interval", 1) val remainingHours = integer("remaining_hours", 13) val groupNotifications = boolean("group_notifications", true) } \ No newline at end of file From 450e7ee8593b0b00fe95216b7613cf9973877512 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 7 Nov 2023 23:59:39 +0100 Subject: [PATCH 203/274] fix(native): module size --- native/jni/src/util.h | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/native/jni/src/util.h b/native/jni/src/util.h index c99fa9dee..54bc33c26 100644 --- a/native/jni/src/util.h +++ b/native/jni/src/util.h @@ -47,12 +47,12 @@ namespace util { continue; } - if (flags[0] != 'r' || flags[2] != 'x') { - continue; + if (addr == 0 && flags[0] == 'r' && flags[2] == 'x') { + addr = start - offset; + } + if (addr != 0) { + size += end - start; } - addr = start - offset; - size = end - start; - break; } fclose(file); return {addr, size}; @@ -96,4 +96,35 @@ namespace util { } return 0; } + + std::vector find_signatures(uintptr_t module_base, uintptr_t size, const std::string &pattern, int offset = 0) { + std::vector results; + std::vector bytes; + std::vector mask; + + for (size_t i = 0; i < pattern.size(); i += 3) { + if (pattern[i] == '?') { + bytes.push_back(0); + mask.push_back('?'); + } else { + bytes.push_back(std::stoi(pattern.substr(i, 2), nullptr, 16)); + mask.push_back('x'); + } + } + + for (size_t i = 0; i < size; i++) { + bool found = true; + for (size_t j = 0; j < bytes.size(); j++) { + if (mask[j] == '?' || bytes[j] == *(char *) (module_base + i + j)) { + continue; + } + found = false; + break; + } + if (found) { + results.push_back(module_base + i + offset); + } + } + return results; + } } \ No newline at end of file From c357825dc75efef756a2fa08996cd088f1c56784 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 8 Nov 2023 01:42:45 +0100 Subject: [PATCH 204/274] refactor(core/e2ee): decryption failure message --- .../features/impl/experiments/EndToEndEncryption.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt index a5dbb9ccd..45b3be793 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/EndToEndEncryption.kt @@ -318,13 +318,19 @@ class EndToEndEncryption : MessagingRuleFeature( if (isMe) { if (conversationParticipants.isEmpty()) return@eachBuffer val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer - setDecryptedMessage(e2eeInterface.decryptMessage(participantId, ciphertext, iv)) + setDecryptedMessage(e2eeInterface.decryptMessage(participantId, ciphertext, iv) ?: run { + context.log.warn("Failed to decrypt message for participant $participantId") + return@eachBuffer + }) return@eachBuffer } if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer - setDecryptedMessage(e2eeInterface.decryptMessage(senderId, ciphertext, iv)) + setDecryptedMessage(e2eeInterface.decryptMessage(senderId, ciphertext, iv)?: run { + context.log.warn("Failed to decrypt message") + return@eachBuffer + }) } }.onFailure { context.log.error("Failed to decrypt message id: $clientMessageId", it) From 8823093b30746348bbfb1310823822f9f738e931 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 8 Nov 2023 01:43:47 +0100 Subject: [PATCH 205/274] feat(core): mark snaps as seen --- common/src/main/assets/lang/en_US.json | 2 + .../common/config/impl/UserInterfaceTweaks.kt | 2 +- .../core/features/impl/messaging/Messaging.kt | 29 ++++++++-- .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 55 +++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 32cef7326..e8c07198b 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -612,6 +612,7 @@ "auto_download": "\u2B07\uFE0F Auto Download", "auto_save": "\uD83D\uDCAC Auto Save Messages", "stealth": "\uD83D\uDC7B Stealth Mode", + "mark_as_seen": "\uD83D\uDC40 Mark Snaps as seen", "conversation_info": "\uD83D\uDC64 Conversation Info", "e2e_encryption": "\uD83D\uDD12 Use E2E Encryption" }, @@ -710,6 +711,7 @@ }, "friend_menu_option": { + "mark_as_seen": "Mark Snaps as seen", "preview": "Preview", "stealth_mode": "Stealth Mode", "auto_download_blacklist": "Auto Download Blacklist", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index fb9c9612f..ca98d6d4c 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -19,7 +19,7 @@ class UserInterfaceTweaks : ConfigContainer() { } val friendFeedMenuButtons = multiple( - "friend_feed_menu_buttons","conversation_info", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() + "friend_feed_menu_buttons","conversation_info", "mark_as_seen", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() ).apply { set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index c38ba4325..4aa2ad9a2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -5,18 +5,20 @@ import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.impl.spying.StealthMode +import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull import me.rhunk.snapenhance.core.wrapper.impl.ConversationManager +import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { var conversationManager: ConversationManager? = null private set - - var openedConversationUUID: SnapUUID? = null private set var lastFetchConversationUserUUID: SnapUUID? = null @@ -27,8 +29,10 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C var lastFocusedMessageId: Long = -1 private set + private val feedCachedSnapMessages = EvictingMap>(100) + override fun init() { - Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { param -> + context.classCache.conversationManager.hookConstructor(HookStage.BEFORE) { param -> conversationManager = ConversationManager(context, param.thisObject()) context.messagingBridge.triggerSessionStart() context.mainActivity?.takeIf { it.intent.getBooleanExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA,false) }?.run { @@ -37,6 +41,8 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } + fun getFeedCachedMessageIds(conversationId: String) = feedCachedSnapMessages[conversationId] + override fun onActivityCreate() { context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> @@ -51,6 +57,19 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } + val myUserId = context.database.myUserId + + context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject() + val interactionInfo = instance.getObjectFieldOrNull("mInteractionInfo") ?: return@hookConstructor + val messages = (interactionInfo.getObjectFieldOrNull("mMessages") as? List<*>)?.map { Message(it) } ?: return@hookConstructor + val conversationId = SnapUUID(instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor).toString() + + feedCachedSnapMessages[conversationId] = messages.filter { msg -> + msg.messageMetadata?.seenBy?.none { it.toString() == myUserId } == true + }.sortedBy { it.orderKey }.mapNotNull { it.messageDescriptor?.messageId } + } + context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param -> val userIdToConversation = (param.arg>(0)) .takeIf { it.isNotEmpty() } @@ -96,12 +115,12 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C lastFocusedMessageId = event.messageId } - Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param -> + context.classCache.conversationManager.hook("fetchMessage", HookStage.BEFORE) { param -> lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any)) lastFocusedMessageId = param.arg(1) } - Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE, { + context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, { hideTypingNotification || stealthMode.canUseRule(openedConversationUUID.toString()) }) { it.setResult(null) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index f1ad2a4df..ebaaea8ce 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -9,9 +9,15 @@ import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.CompoundButton +import android.widget.ProgressBar import android.widget.Switch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.UserConversationLink @@ -29,6 +35,9 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.random.Random class FriendFeedInfoMenu : AbstractMenu() { private fun getImageDrawable(url: String): Drawable { @@ -101,6 +110,44 @@ class FriendFeedInfoMenu : AbstractMenu() { } } + private fun markAsSeen(conversationId: String) { + val messaging = context.feature(Messaging::class) + val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run { + context.shortToast("No recent snaps found") + return + } + + var job: Job? = null + val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle("Processing...") + .setView(ProgressBar(context.mainActivity).apply { + setPadding(10, 10, 10, 10) + }) + .setOnDismissListener { job?.cancel() } + .show() + + context.coroutineScope.launch(Dispatchers.IO) { + messageIds.forEach { messageId -> + suspendCoroutine { continuation -> + messaging.conversationManager?.updateMessage(conversationId, messageId, MessageUpdate.READ) { + continuation.resume(Unit) + if (it != null && it != "DUPLICATEREQUEST") { + context.log.error("Error marking message as read $it") + } + } + } + delay(Random.nextLong(20, 60)) + context.runOnUiThread { + dialog.setTitle("Processing... (${messageIds.indexOf(messageId) + 1}/${messageIds.size})") + } + } + }.also { job = it }.invokeOnCompletion { + context.runOnUiThread { + dialog.dismiss() + } + } + } + private fun showPreview(userId: String?, conversationId: String) { //query message val messageLogger = context.feature(MessageLogger::class) @@ -253,5 +300,13 @@ class FriendFeedInfoMenu : AbstractMenu() { { ruleFeature.setState(conversationId, it) } ) } + + if (friendFeedMenuOptions.contains("mark_as_seen")) { + viewConsumer(Button(view.context).apply { + text = modContext.translation["friend_menu_option.mark_as_seen"] + applyTheme(view.width, hasRadius = true) + setOnClickListener { markAsSeen(conversationId) } + }) + } } } \ No newline at end of file From 81f626cc3be11789f03aac1321cf72ffb11d6a2f Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 8 Nov 2023 17:30:48 +0100 Subject: [PATCH 206/274] feat(core/camera_tweaks): black photos --- common/src/main/assets/lang/en_US.json | 4 ++++ .../snapenhance/common/config/impl/Camera.kt | 1 + .../core/features/impl/tweaks/CameraTweaks.kt | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index e8c07198b..ee0b5a792 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -434,6 +434,10 @@ "name": "Disable Camera", "description": "Prevents Snapchat from using the cameras available on your device" }, + "black_photos": { + "name": "Black Photos", + "description": "Replaces captured photos with a black background\nVideos are not affected" + }, "immersive_camera_preview": { "name": "Immersive Preview", "description": "Prevents Snapchat from Cropping the Camera preview\nThis might cause the camera to flicker on some devices" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt index d7825c766..eb431905a 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt @@ -40,6 +40,7 @@ class Camera : ConfigContainer() { val disable = boolean("disable_camera") val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE) } + val blackPhotos = boolean("black_photos") val overridePreviewResolution get() = _overridePreviewResolution val overridePictureResolution get() = _overridePictureResolution val customFrameRate = unique("custom_frame_rate", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt index 0c0de5b51..5d3ce8769 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt @@ -4,9 +4,11 @@ import android.Manifest import android.annotation.SuppressLint import android.content.ContextWrapper import android.content.pm.PackageManager +import android.graphics.Bitmap import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics.Key import android.hardware.camera2.CameraManager +import android.media.Image import android.util.Range import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams @@ -15,6 +17,8 @@ import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.hook.hookConstructor import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.impl.ScSize +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { @@ -71,5 +75,20 @@ class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACT } } } + + if (context.config.camera.blackPhotos.get()) { + findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param -> + val image = param.thisObject() as? Image ?: return@hook + val planes = param.getResult() as? Array<*> ?: return@hook + val output = ByteArrayOutputStream() + Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).apply { + compress(Bitmap.CompressFormat.JPEG, 100, output) + recycle() + } + planes.filterNotNull().forEach { plane -> + plane.setObjectField("mBuffer", ByteBuffer.wrap(output.toByteArray())) + } + } + } } } \ No newline at end of file From 4bf421441bdfbd2fae3cf658ccc9711a035e2729 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 8 Nov 2023 19:24:15 +0100 Subject: [PATCH 207/274] fix(core/better_notifications): handle status message - add new content types --- common/src/main/assets/lang/en_US.json | 27 ++++++++- .../common/config/impl/MessagingTweaks.kt | 2 +- .../snapenhance/common/data/SnapEnums.kt | 4 +- .../features/impl/messaging/Notifications.kt | 57 ++++++++++++------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index ee0b5a792..b422f5bd6 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -605,8 +605,8 @@ "always_dark": "Always Dark" }, "better_notifications": { - "chat": "Show chat messages", - "snap": "Show media", + "chat_preview": "Show a preview of chat", + "media_preview": "Show a preview of media", "reply_button": "Add reply button", "download_button": "Add download button", "mark_as_read_button": "Mark as Read button", @@ -722,6 +722,29 @@ "anti_auto_save": "Anti Auto Save" }, + "content_type": { + "CHAT": "Chat", + "SNAP": "Snap", + "EXTERNAL_MEDIA": "External Media", + "NOTE": "Audio Note", + "STICKER": "Sticker", + "STATUS": "Status", + "LOCATION": "Location", + "STATUS_SAVE_TO_CAMERA_ROLL": "Saved to Camera Roll", + "STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Screenshot", + "STATUS_CONVERSATION_CAPTURE_RECORD": "Screen Record", + "STATUS_CALL_MISSED_VIDEO": "Missed Video Call", + "STATUS_CALL_MISSED_AUDIO": "Missed Audio Call", + "LIVE_LOCATION_SHARE": "Live Location Share", + "CREATIVE_TOOL_ITEM": "Creative Tool Item", + "FAMILY_CENTER_INVITE": "Family Center Invite", + "FAMILY_CENTER_ACCEPT": "Family Center Accept", + "FAMILY_CENTER_LEAVE": "Family Center Leave", + "STATUS_PLUS_GIFT": "Status Plus Gift", + "TINY_SNAP": "Tiny Snap", + "STATUS_COUNTDOWN": "Countdown" + }, + "chat_action_menu": { "preview_button": "Preview", "download_button": "Download", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 245426166..650afbee0 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -24,7 +24,7 @@ class MessagingTweaks : ConfigContainer() { nativeHooks() } val instantDelete = boolean("instant_delete") { requireRestart() } - val betterNotifications = multiple("better_notifications", "snap", "chat", "reply_button", "download_button", "mark_as_read_button", "group") { requireRestart() } + val betterNotifications = multiple("better_notifications", "chat_preview", "media_preview", "reply_button", "download_button", "mark_as_read_button", "group") { requireRestart() } val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt index 10d7447c4..7201ed78a 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SnapEnums.kt @@ -71,7 +71,9 @@ enum class ContentType(val id: Int) { FAMILY_CENTER_INVITE(15), FAMILY_CENTER_ACCEPT(16), FAMILY_CENTER_LEAVE(17), - STATUS_PLUS_GIFT(18); + STATUS_PLUS_GIFT(18), + TINY_SNAP(19), + STATUS_COUNTDOWN(20); companion object { fun fromId(i: Int): ContentType { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index b57062a57..7c98253ab 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -94,7 +94,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN notification ) as Notification.Builder - private fun setNotificationText(notification: Notification, conversationId: String) { + private fun computeNotificationMessages(notification: Notification, conversationId: String) { val messageText = StringBuilder().apply { cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }.forEach { if (isNotEmpty()) append("\n") @@ -147,7 +147,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } newAction(translations["button.download"], ACTION_DOWNLOAD, { - betterNotificationFilter.contains("download_button") && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP) + betterNotificationFilter.contains("download_button") && betterNotificationFilter.contains("media_preview") && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP) }) {} newAction(translations["button.mark_as_read"], ACTION_MARK_AS_READ, { @@ -182,7 +182,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN withContext(Dispatchers.Main) { updateNotification(notificationId) { notification -> notification.flags = notification.flags or Notification.FLAG_ONLY_ALERT_ONCE - setNotificationText(notification, conversationId) + computeNotificationMessages(notification, conversationId) } } } @@ -256,7 +256,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN private fun sendNotification(message: Message, notificationData: NotificationData, forceCreate: Boolean) { val conversationId = message.messageDescriptor?.conversationId.toString() - val notificationId = if (forceCreate) System.nanoTime().toInt() else notificationData.id + val notificationId = if (forceCreate) System.nanoTime().toInt() else message.messageDescriptor?.conversationId?.toBytes().contentHashCode() sentNotifications.computeIfAbsent(notificationId) { conversationId } if (betterNotificationFilter.contains("group")) { @@ -286,7 +286,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN }.send() } - private fun onMessageReceived(data: NotificationData, message: Message) { + private fun onMessageReceived(data: NotificationData, notificationType: String, message: Message) { val conversationId = message.messageDescriptor?.conversationId.toString() val orderKey = message.orderKey ?: return val senderUsername by lazy { @@ -295,21 +295,30 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } ?: "Unknown" } - val formatUsername: (String) -> String = { "$senderUsername: $it" } - val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { sortedMapOf() } } - val appendNotifications: () -> Unit = { setNotificationText(data.notification, conversationId)} + val contentType = message.messageContent!!.contentType!!.let { contentType -> + when { + notificationType.contains("screenshot") -> ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT + else -> contentType + } + } + val computeMessages: () -> Unit = { computeNotificationMessages(data.notification, conversationId)} + fun setNotificationText(text: String, includeUsername: Boolean = true) { + cachedMessages.computeIfAbsent(conversationId) { + sortedMapOf() + }[orderKey] = if (includeUsername) "$senderUsername: $text" else text + } - when (val contentType = message.messageContent!!.contentType) { - ContentType.NOTE -> { - notificationCache[orderKey] = formatUsername("sent audio note") - appendNotifications() - } + when ( + contentType.takeIf { + (it != ContentType.SNAP && it != ContentType.EXTERNAL_MEDIA) || betterNotificationFilter.contains("media_preview") + } ?: ContentType.UNKNOWN + ) { ContentType.CHAT -> { ProtoReader(message.messageContent!!.content!!).getString(2, 1)?.trim()?.let { - notificationCache[orderKey] = formatUsername(it) + setNotificationText(it) } - appendNotifications() + computeMessages() } ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { val mediaReferences = MessageDecoder.getMediaReferences( @@ -353,10 +362,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } } else -> { - notificationCache[orderKey] = formatUsername("sent ${contentType?.name?.lowercase()}") - appendNotifications() + setNotificationText("[" + context.translation.getCategory("content_type")[contentType.name] + "]") + computeMessages() } } + if (!betterNotificationFilter.contains("chat_preview")) return sendNotification(message, data, false) } @@ -377,9 +387,8 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } ?: return@hook val serverMessageId = extras.getString("message_id") ?: return@hook - val notificationType = extras.getString("notification_type") ?: return@hook - - if (betterNotificationFilter.none { notificationType.contains(it, ignoreCase = true) }) return@hook + val notificationType = extras.getString("notification_type")?.lowercase() ?: return@hook + if (!betterNotificationFilter.contains("chat_preview") && !betterNotificationFilter.contains("media_preview")) return@hook param.setResult(null) val conversationManager = context.feature(Messaging::class).conversationManager ?: return@hook @@ -387,10 +396,16 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN context.coroutineScope.launch(coroutineDispatcher) { suspendCoroutine { continuation -> conversationManager.fetchMessageByServerId(conversationId, serverMessageId, onSuccess = { - onMessageReceived(notificationData, it) + if (it.senderId.toString() == context.database.myUserId) { + param.invokeOriginal() + continuation.resumeWith(Result.success(Unit)) + return@fetchMessageByServerId + } + onMessageReceived(notificationData, notificationType, it) continuation.resumeWith(Result.success(Unit)) }, onError = { context.log.error("Failed to fetch message id ${serverMessageId}: $it") + param.invokeOriginal() continuation.resumeWith(Result.success(Unit)) }) } From 691510235b9b9ca7f72ff1e60ded77d950c848b6 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 9 Nov 2023 20:31:53 +0100 Subject: [PATCH 208/274] perf(core): database corruption --- .../core/action/impl/CleanCache.kt | 1 + .../core/database/DatabaseAccess.kt | 175 ++++++++++-------- .../core/features/impl/messaging/Messaging.kt | 2 +- 3 files changed, 102 insertions(+), 76 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt index 19bd4b15f..ff7cee6ee 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt @@ -17,6 +17,7 @@ class CleanCache : AbstractAction() { "databases/journal.db", "databases/arroyo.db", "databases/arroyo.db-wal", + "databases/arroyo.db-shm", "databases/native_content_manager/*" ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 0b47a827e..43c37e757 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -1,89 +1,119 @@ package me.rhunk.snapenhance.core.database -import android.annotation.SuppressLint +import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabaseCorruptException import me.rhunk.snapenhance.common.database.DatabaseObject import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.StoryEntry import me.rhunk.snapenhance.common.database.impl.UserConversationLink +import me.rhunk.snapenhance.common.util.ktx.getInteger import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.manager.Manager -import java.lang.ref.WeakReference +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import java.io.File -inline fun SQLiteDatabase.performOperation(crossinline query: SQLiteDatabase.() -> T?): T? { - synchronized(this) { - if (!isOpen) { - return null - } + +class DatabaseAccess( + private val context: ModContext +) : Manager { + private val mainDb by lazy { openLocalDatabase("main.db") } + private val arroyoDb by lazy { openLocalDatabase("arroyo.db") } + + private inline fun SQLiteDatabase.performOperation(crossinline query: SQLiteDatabase.() -> T?): T? { return runCatching { query() }.onFailure { - CoreLogger.xposedLog("Database operation failed", it) + context.log.error("Database operation failed", it) + }.getOrNull() + } + + private var hasShownDatabaseError = false + + private fun showDatabaseError(databasePath: String, throwable: Throwable) { + if (hasShownDatabaseError) return + hasShownDatabaseError = true + context.runOnUiThread { + if (context.mainActivity == null) return@runOnUiThread + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle("SnapEnhance") + .setMessage("Failed to query $databasePath database!\n\n${throwable.localizedMessage}\n\nRestarting Snapchat may fix this issue. If the issue persists, try to clean the app data and cache.") + .setPositiveButton("Restart Snapchat") { _, _ -> + File(databasePath).takeIf { it.exists() }?.delete() + context.softRestartApp() + } + .setNegativeButton("Dismiss") { dialog, _ -> + dialog.dismiss() + }.show() + } + } + + private fun SQLiteDatabase.safeRawQuery(query: String, args: Array? = null): Cursor? { + return runCatching { + rawQuery(query, args) + }.onFailure { + if (it !is SQLiteDatabaseCorruptException) { + context.log.error("Failed to execute query $query", it) + showDatabaseError(this.path, it) + return@onFailure + } + context.log.warn("Database ${this.path} is corrupted!") + context.androidContext.deleteDatabase(this.path) + showDatabaseError(this.path, it) }.getOrNull() } -} -@SuppressLint("Range") -class DatabaseAccess( - private val context: ModContext -) : Manager { private val dmOtherParticipantCache by lazy { - (openArroyo().performOperation { - rawQuery( + (arroyoDb?.performOperation { + safeRawQuery( "SELECT client_conversation_id, user_id FROM user_conversation WHERE conversation_type = 0 AND user_id != ?", arrayOf(myUserId) - ).use { query -> + )?.use { query -> val participants = mutableMapOf() if (!query.moveToFirst()) { return@performOperation null } do { - participants[query.getString(query.getColumnIndex("client_conversation_id"))] = - query.getString(query.getColumnIndex("user_id")) + participants[query.getStringOrNull("client_conversation_id")!!] = query.getStringOrNull("user_id")!! } while (query.moveToNext()) participants } } ?: emptyMap()).toMutableMap() } - private var databaseWeakMap = mutableMapOf?>() - - private fun openLocalDatabase(fileName: String): SQLiteDatabase { - if (databaseWeakMap.containsKey(fileName)) { - val database = databaseWeakMap[fileName]?.get() - if (database != null && database.isOpen) return database - } - + private fun openLocalDatabase(fileName: String): SQLiteDatabase? { + val dbPath = context.androidContext.getDatabasePath(fileName) + if (!dbPath.exists()) return null return runCatching { SQLiteDatabase.openDatabase( - context.androidContext.getDatabasePath(fileName).absolutePath, + dbPath.absolutePath, null, - SQLiteDatabase.OPEN_READONLY - )?.also { - databaseWeakMap[fileName] = WeakReference(it) - } + SQLiteDatabase.OPEN_READONLY or SQLiteDatabase.NO_LOCALIZED_COLLATORS + ) }.onFailure { - context.log.error("Failed to open database $fileName, restarting!", it) - }.getOrNull() ?: throw IllegalStateException("Failed to open database $fileName") + context.log.error("Failed to open database $fileName!", it) + showDatabaseError(dbPath.absolutePath, it) + }.getOrNull() } - private fun openMain() = openLocalDatabase("main.db") - private fun openArroyo() = openLocalDatabase("arroyo.db") + fun hasMain(): Boolean = mainDb?.isOpen == true + fun hasArroyo(): Boolean = arroyoDb?.isOpen == true - fun hasMain(): Boolean = context.androidContext.getDatabasePath("main.db").exists() - fun hasArroyo(): Boolean = context.androidContext.getDatabasePath("arroyo.db").exists() + fun finalize() { + mainDb?.close() + arroyoDb?.close() + context.log.verbose("Database closed") + } - private fun readDatabaseObject( + private fun SQLiteDatabase.readDatabaseObject( obj: T, - database: SQLiteDatabase, table: String, where: String, args: Array - ): T? = database.rawQuery("SELECT * FROM $table WHERE $where", args).use { + ): T? = this.safeRawQuery("SELECT * FROM $table WHERE $where", args)?.use { if (!it.moveToFirst()) { return null } @@ -96,10 +126,9 @@ class DatabaseAccess( } fun getFeedEntryByUserId(userId: String): FriendFeedEntry? { - return openMain().performOperation { + return mainDb?.performOperation { readDatabaseObject( FriendFeedEntry(), - this, "FriendsFeedView", "friendUserId = ?", arrayOf(userId) @@ -108,10 +137,10 @@ class DatabaseAccess( } val myUserId by lazy { - openArroyo().performOperation { - rawQuery(buildString { + arroyoDb?.performOperation { + safeRawQuery(buildString { append("SELECT value FROM required_values WHERE key = 'USERID'") - }, null).use { query -> + }, null)?.use { query -> if (!query.moveToFirst()) { return@performOperation null } @@ -121,10 +150,9 @@ class DatabaseAccess( } fun getFeedEntryByConversationId(conversationId: String): FriendFeedEntry? { - return openMain().performOperation { + return mainDb?.performOperation { readDatabaseObject( FriendFeedEntry(), - this, "FriendsFeedView", "key = ?", arrayOf(conversationId) @@ -133,10 +161,9 @@ class DatabaseAccess( } fun getFriendInfo(userId: String): FriendInfo? { - return openMain().performOperation { + return mainDb?.performOperation { readDatabaseObject( FriendInfo(), - this, "FriendWithUsername", "userId = ?", arrayOf(userId) @@ -145,11 +172,11 @@ class DatabaseAccess( } fun getFeedEntries(limit: Int): List { - return openMain().performOperation { - rawQuery( + return mainDb?.performOperation { + safeRawQuery( "SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?", arrayOf(limit.toString()) - ).use { query -> + )?.use { query -> val list = mutableListOf() while (query.moveToNext()) { val friendFeedEntry = FriendFeedEntry() @@ -164,10 +191,9 @@ class DatabaseAccess( } fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? { - return openArroyo().performOperation { + return arroyoDb?.performOperation { readDatabaseObject( ConversationMessage(), - this, "conversation_message", "client_message_id = ?", arrayOf(clientMessageId.toString()) @@ -176,24 +202,23 @@ class DatabaseAccess( } fun getConversationType(conversationId: String): Int? { - return openArroyo().performOperation { - rawQuery( + return arroyoDb?.performOperation { + safeRawQuery( "SELECT conversation_type FROM user_conversation WHERE client_conversation_id = ?", arrayOf(conversationId) - ).use { query -> + )?.use { query -> if (!query.moveToFirst()) { return@performOperation null } - query.getInt(query.getColumnIndex("conversation_type")) + query.getInteger("conversation_type") } } } fun getConversationLinkFromUserId(userId: String): UserConversationLink? { - return openArroyo().performOperation { + return arroyoDb?.performOperation { readDatabaseObject( UserConversationLink(), - this, "user_conversation", "user_id = ? AND conversation_type = 0", arrayOf(userId) @@ -203,17 +228,17 @@ class DatabaseAccess( fun getDMOtherParticipant(conversationId: String): String? { if (dmOtherParticipantCache.containsKey(conversationId)) return dmOtherParticipantCache[conversationId] - return openArroyo().performOperation { - rawQuery( + return arroyoDb?.performOperation { + safeRawQuery( "SELECT user_id FROM user_conversation WHERE client_conversation_id = ? AND conversation_type = 0", arrayOf(conversationId) - ).use { query -> + )?.use { query -> val participants = mutableListOf() if (!query.moveToFirst()) { return@performOperation null } do { - participants.add(query.getString(query.getColumnIndex("user_id"))) + participants.add(query.getStringOrNull("user_id")!!) } while (query.moveToNext()) participants.firstOrNull { it != myUserId } } @@ -222,23 +247,23 @@ class DatabaseAccess( fun getStoryEntryFromId(storyId: String): StoryEntry? { - return openMain().performOperation { - readDatabaseObject(StoryEntry(), this, "Story", "storyId = ?", arrayOf(storyId)) + return mainDb?.performOperation { + readDatabaseObject(StoryEntry(), "Story", "storyId = ?", arrayOf(storyId)) } } fun getConversationParticipants(conversationId: String): List? { - return openArroyo().performOperation { - rawQuery( + return arroyoDb?.performOperation { + safeRawQuery( "SELECT user_id FROM user_conversation WHERE client_conversation_id = ?", arrayOf(conversationId) - ).use { + )?.use { if (!it.moveToFirst()) { return@performOperation null } val participants = mutableListOf() do { - participants.add(it.getString(it.getColumnIndex("user_id"))) + participants.add(it.getStringOrNull("user_id")!!) } while (it.moveToNext()) participants } @@ -249,11 +274,11 @@ class DatabaseAccess( conversationId: String, limit: Int ): List? { - return openArroyo().performOperation { - rawQuery( + return arroyoDb?.performOperation { + safeRawQuery( "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?", arrayOf(conversationId, limit.toString()) - ).use { query -> + )?.use { query -> if (!query.moveToFirst()) { return@performOperation null } @@ -269,7 +294,7 @@ class DatabaseAccess( } fun getAddSource(userId: String): String? { - return openMain().performOperation { + return mainDb?.performOperation { rawQuery( "SELECT addSource FROM FriendWhoAddedMe WHERE userId = ?", arrayOf(userId) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index 4aa2ad9a2..8ee08cd21 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -57,13 +57,13 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } - val myUserId = context.database.myUserId context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() val interactionInfo = instance.getObjectFieldOrNull("mInteractionInfo") ?: return@hookConstructor val messages = (interactionInfo.getObjectFieldOrNull("mMessages") as? List<*>)?.map { Message(it) } ?: return@hookConstructor val conversationId = SnapUUID(instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor).toString() + val myUserId = context.database.myUserId feedCachedSnapMessages[conversationId] = messages.filter { msg -> msg.messageMetadata?.seenBy?.none { it.toString() == myUserId } == true From 9365528c7477dc62b560f5dfa750b0fed6b6707d Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 10 Nov 2023 18:36:37 +0100 Subject: [PATCH 209/274] fix(core/ui): close opera context action menu --- .../snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt index 25445e131..183243cbf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt @@ -10,6 +10,7 @@ import android.widget.ScrollView import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent import me.rhunk.snapenhance.core.util.ktx.getId @SuppressLint("DiscouragedApi") @@ -71,7 +72,10 @@ class OperaContextActionMenu : AbstractMenu() { linearLayout.addView(Button(view.context).apply { text = translation["opera_context_menu.download"] - setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync() } + setOnClickListener { + mediaDownloader.downloadLastOperaMediaAsync() + parentView.triggerCloseTouchEvent() + } applyTheme(isAmoled = false) }) From b120b6a27d198a8fbc03991db9c8d888016190ea Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 10 Nov 2023 19:21:19 +0100 Subject: [PATCH 210/274] feat: bypass screenshot detection --- common/src/main/assets/lang/en_US.json | 4 ++++ .../common/config/impl/MessagingTweaks.kt | 1 + .../impl/tweaks/BypassScreenshotDetection.kt | 23 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 2 ++ 4 files changed, 30 insertions(+) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index b422f5bd6..3e6c174fd 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -312,6 +312,10 @@ "name": "Messaging", "description": "Change how you interact with friends", "properties": { + "bypass_screenshot_detection": { + "name": "Bypass Screenshot Detection", + "description": "Prevents Snapchat from detecting when you take a screenshot" + }, "anonymous_story_viewing": { "name": "Anonymous Story Viewing", "description": "Prevents anyone from knowing you've seen their story" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 650afbee0..82634c7bb 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -5,6 +5,7 @@ import me.rhunk.snapenhance.common.config.FeatureNotice import me.rhunk.snapenhance.common.data.NotificationType class MessagingTweaks : ConfigContainer() { + val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() } val anonymousStoryViewing = boolean("anonymous_story_viewing") val hideBitmojiPresence = boolean("hide_bitmoji_presence") val hideTypingNotifications = boolean("hide_typing_notifications") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt new file mode 100644 index 000000000..f0d98d2e7 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/BypassScreenshotDetection.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class BypassScreenshotDetection : Feature("BypassScreenshotDetection", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + if (!context.config.messaging.bypassScreenshotDetection.get()) return + ContentResolver::class.java.methods.first { + it.name == "registerContentObserver" && + it.parameterTypes.contentEquals(arrayOf(android.net.Uri::class.java, Boolean::class.javaPrimitiveType, ContentObserver::class.java)) + }.hook(HookStage.BEFORE) { param -> + val uri = param.arg(0) + if (uri.host != "media") return@hook + param.setResult(null) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 7678df5e2..b6d631b15 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -17,6 +17,7 @@ import me.rhunk.snapenhance.core.features.impl.messaging.* import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks +import me.rhunk.snapenhance.core.features.impl.tweaks.BypassScreenshotDetection import me.rhunk.snapenhance.core.features.impl.ui.* import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.manager.Manager @@ -103,6 +104,7 @@ class FeatureManager( CallStartConfirmation::class, SnapPreview::class, InstantDelete::class, + BypassScreenshotDetection::class, ) initializeFeatures() From 6fa79937124b4b2040631aec3b923ca0bd29d7ce Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 10 Nov 2023 21:24:05 +0100 Subject: [PATCH 211/274] feat(core/camera_tweaks): custom resolution --- .../rhunk/snapenhance/ui/util/AlertDialogs.kt | 19 +++++++++++-------- common/src/main/assets/lang/en_US.json | 8 ++++++++ .../common/config/ConfigObjects.kt | 3 ++- .../snapenhance/common/config/impl/Camera.kt | 6 ++++-- .../core/features/impl/tweaks/CameraTweaks.kt | 17 ++++++++++------- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt index f8b2e16c2..add1aae7b 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -176,7 +176,7 @@ class AlertDialogs( val focusRequester = remember { FocusRequester() } DefaultDialogCard { - val fieldValue = remember { + var fieldValue by remember { mutableStateOf(property.value.get().toString().let { TextFieldValue( text = it, @@ -193,10 +193,8 @@ class AlertDialogs( focusRequester.requestFocus() } .focusRequester(focusRequester), - value = fieldValue.value, - onValueChange = { - fieldValue.value = it - }, + value = fieldValue, + onValueChange = { fieldValue = it }, keyboardOptions = when (property.key.dataType.type) { DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) @@ -215,22 +213,27 @@ class AlertDialogs( Text(text = translation["button.cancel"]) } Button(onClick = { + if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) { + dismiss() + return@Button + } + when (property.key.dataType.type) { DataProcessors.Type.INTEGER -> { runCatching { - property.value.setAny(fieldValue.value.text.toInt()) + property.value.setAny(fieldValue.text.toInt()) }.onFailure { property.value.setAny(0) } } DataProcessors.Type.FLOAT -> { runCatching { - property.value.setAny(fieldValue.value.text.toFloat()) + property.value.setAny(fieldValue.text.toFloat()) }.onFailure { property.value.setAny(0f) } } - else -> property.value.setAny(fieldValue.value.text) + else -> property.value.setAny(fieldValue.text) } dismiss() }) { diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 3e6c174fd..f15c03d0f 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -454,6 +454,14 @@ "name": "Override Picture Resolution", "description": "Overrides the picture resolution" }, + "custom_preview_resolution": { + "name": "Custom Preview Resolution", + "description": "Sets a custom camera preview resolution, width x height (e.g. 1920x1080).\nThe custom resolution must be supported by your device" + }, + "custom_picture_resolution": { + "name": "Custom Picture Resolution", + "description": "Sets a custom picture resolution, width x height (e.g. 1920x1080).\nThe custom resolution must be supported by your device" + }, "custom_frame_rate": { "name": "Custom Frame Rate", "description": "Overrides the camera frame rate" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt index cb2308c20..22bfc1a1c 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -38,7 +38,8 @@ class ConfigParams( var icon: String? = null, var disabledKey: String? = null, var customTranslationPath: String? = null, - var customOptionTranslationPath: String? = null + var customOptionTranslationPath: String? = null, + var inputCheck: ((String) -> Boolean)? = { true }, ) { val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt index eb431905a..827da10b0 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt @@ -41,10 +41,12 @@ class Camera : ConfigContainer() { val disable = boolean("disable_camera") val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE) } val blackPhotos = boolean("black_photos") - val overridePreviewResolution get() = _overridePreviewResolution - val overridePictureResolution get() = _overridePictureResolution val customFrameRate = unique("custom_frame_rate", "5", "10", "20", "25", "30", "48", "60", "90", "120" ) { addNotices(FeatureNotice.UNSTABLE); addFlags(ConfigFlag.NO_TRANSLATE) } val forceCameraSourceEncoding = boolean("force_camera_source_encoding") + val overridePreviewResolution get() = _overridePreviewResolution + val overridePictureResolution get() = _overridePictureResolution + val customPreviewResolution = string("custom_preview_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } } + val customPictureResolution = string("custom_picture_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt index 5d3ce8769..144231570 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/CameraTweaks.kt @@ -22,13 +22,14 @@ import java.nio.ByteBuffer class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private fun parseResolution(resolution: String): IntArray { - return resolution.split("x").map { it.toInt() }.toIntArray() + private fun parseResolution(resolution: String): IntArray? { + return runCatching { resolution.split("x").map { it.toInt() }.toIntArray() }.getOrNull() } @SuppressLint("MissingPermission", "DiscouragedApi") override fun onActivityCreate() { - if (context.config.camera.disable.get()) { + val config = context.config.camera + if (config.disable.get()) { ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> val permission = param.arg(0) if (permission == Manifest.permission.CAMERA) { @@ -41,10 +42,12 @@ class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACT } } - val previewResolutionConfig = context.config.camera.overridePreviewResolution.getNullable()?.let { parseResolution(it) } - val captureResolutionConfig = context.config.camera.overridePictureResolution.getNullable()?.let { parseResolution(it) } + val previewResolutionConfig = config.customPreviewResolution.getNullable()?.takeIf { it.isNotEmpty() }?.let { parseResolution(it) } + ?: config.overridePreviewResolution.getNullable()?.let { parseResolution(it) } + val captureResolutionConfig = config.customPictureResolution.getNullable()?.takeIf { it.isNotEmpty() }?.let { parseResolution(it) } + ?: config.overridePictureResolution.getNullable()?.let { parseResolution(it) } - context.config.camera.customFrameRate.getNullable()?.also { value -> + config.customFrameRate.getNullable()?.also { value -> val customFrameRate = value.toInt() CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param -> val key = param.arg>(0) @@ -76,7 +79,7 @@ class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACT } } - if (context.config.camera.blackPhotos.get()) { + if (config.blackPhotos.get()) { findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param -> val image = param.thisObject() as? Image ?: return@hook val planes = param.getResult() as? Array<*> ?: return@hook From 673b86618d25d8d0664647a3fd1aa475e462468d Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 10 Nov 2023 23:07:56 +0100 Subject: [PATCH 212/274] feat(core/ui): opera download icon --- common/src/main/assets/lang/en_US.json | 4 ++ .../common/config/impl/DownloaderConfig.kt | 1 + .../core/ui/menu/impl/MenuViewInjector.kt | 8 +++- .../ui/menu/impl/OperaDownloadIconMenu.kt | 39 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index f15c03d0f..ca5e5a56a 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -166,6 +166,10 @@ "name": "Download Profile Pictures", "description": "Allows you to download Profile Pictures from the profile page" }, + "opera_download_button": { + "name": "Opera Download Button", + "description": "Adds a download button on the top right corner when viewing a Snap" + }, "chat_download_context_menu": { "name": "Chat Download Context Menu", "description": "Allows you to download media from a conversation by long-pressing them" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt index 6f9403f71..d0c73d510 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt @@ -42,6 +42,7 @@ class DownloaderConfig : ConfigContainer() { addFlags(ConfigFlag.NO_TRANSLATE) } val downloadProfilePictures = boolean("download_profile_pictures") { requireRestart() } + val operaDownloadButton = boolean("opera_download_button") { requireRestart() } val chatDownloadContextMenu = boolean("chat_download_context_menu") val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt index ebe7fdf73..f8d68cd90 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt @@ -27,9 +27,10 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar @SuppressLint("ResourceType") override fun asyncOnActivityCreate() { + menuMap[OperaContextActionMenu::class] = OperaContextActionMenu() + menuMap[OperaDownloadIconMenu::class] = OperaDownloadIconMenu() menuMap[SettingsGearInjector::class] = SettingsGearInjector() menuMap[FriendFeedInfoMenu::class] = FriendFeedInfoMenu() - menuMap[OperaContextActionMenu::class] = OperaContextActionMenu() menuMap[ChatActionMenu::class] = ChatActionMenu() menuMap[SettingsMenu::class] = SettingsMenu() @@ -42,6 +43,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar val actionMenu = context.resources.getIdentifier("action_menu", "id") val componentsHolder = context.resources.getIdentifier("components_holder", "id") val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id") + val contextMenuButtonIconView = context.resources.getIdentifier("context_menu_button_icon_view", "id") context.event.subscribe(AddViewEvent::class) { event -> val originalAddView: (View) -> Unit = { @@ -57,6 +59,10 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar val childView: View = event.view menuMap[OperaContextActionMenu::class]!!.inject(viewGroup, childView, originalAddView) + if (childView.id == contextMenuButtonIconView) { + menuMap[OperaDownloadIconMenu::class]!!.inject(viewGroup, childView, originalAddView) + } + if (event.parent.id == componentsHolder && childView.id == feedNewChat) { menuMap[SettingsGearInjector::class]!!.inject(viewGroup, childView, originalAddView) return@subscribe diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt new file mode 100644 index 000000000..5dcb0e619 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt @@ -0,0 +1,39 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.ktx.getDimens +import me.rhunk.snapenhance.core.util.ktx.getDrawable + +class OperaDownloadIconMenu : AbstractMenu() { + private val downloadSvgDrawable by lazy { context.resources.getDrawable("svg_download", context.androidContext.theme) } + private val actionMenuIconSize by lazy { context.resources.getDimens("action_menu_icon_size") } + private val actionMenuIconMargin by lazy { context.resources.getDimens("action_menu_icon_margin") } + private val actionMenuIconMarginTop by lazy { context.resources.getDimens("action_menu_icon_margin_top") } + + override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + if (!context.config.downloader.operaDownloadButton.get()) return + + parent.addView(ImageView(view.context).apply { + setImageDrawable(downloadSvgDrawable) + setColorFilter(Color.WHITE) + layoutParams = FrameLayout.LayoutParams( + actionMenuIconSize, + actionMenuIconSize + ).apply { + setMargins(0, actionMenuIconMarginTop * 2 + actionMenuIconSize, 0, 0) + marginEnd = actionMenuIconMargin + gravity = Gravity.TOP or Gravity.END + } + setOnClickListener { + this@OperaDownloadIconMenu.context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() + } + }, 0) + } +} \ No newline at end of file From a106334713ab7d6f4c0e3a844c0b4f88f9781066 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 00:46:37 +0100 Subject: [PATCH 213/274] fix(common/config): change listener sync --- .../kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt index 881ad4cf8..11aa8abb9 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt @@ -97,10 +97,13 @@ class ModConfig( } } + val oldConfig = runCatching { file.read().toString(Charsets.UTF_8) }.getOrNull() + file.write(exportToString().toByteArray(Charsets.UTF_8)) + configStateListener?.also { runCatching { compareDiff(createRootConfig().apply { - fromJson(gson.fromJson(file.read().toString(Charsets.UTF_8), JsonObject::class.java)) + fromJson(gson.fromJson(oldConfig ?: return@runCatching, JsonObject::class.java)) }, root) if (configChanged) { @@ -112,8 +115,6 @@ class ModConfig( AbstractLogger.directError("Error while calling config state listener", it, "ConfigStateListener") } } - - file.write(exportToString().toByteArray(Charsets.UTF_8)) } fun loadFromString(string: String) { From a568b9c1c615d1d09d232bce960adc4fc747ebb7 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 00:48:13 +0100 Subject: [PATCH 214/274] feat: hide peek a peek --- common/src/main/assets/lang/en_US.json | 4 ++++ .../snapenhance/common/config/impl/MessagingTweaks.kt | 1 + .../core/features/impl/messaging/Messaging.kt | 11 ++++++----- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index ca5e5a56a..de871a4ba 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -324,6 +324,10 @@ "name": "Anonymous Story Viewing", "description": "Prevents anyone from knowing you've seen their story" }, + "hide_peek_a_peek": { + "name": "Hide Peek-a-Peek", + "description": "Prevents notification from being sent when you half swipe into a chat" + }, "hide_bitmoji_presence": { "name": "Hide Bitmoji Presence", "description": "Prevents your Bitmoji from popping up while in Chat" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 82634c7bb..1e2b53259 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -7,6 +7,7 @@ import me.rhunk.snapenhance.common.data.NotificationType class MessagingTweaks : ConfigContainer() { val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() } val anonymousStoryViewing = boolean("anonymous_story_viewing") + val hidePeekAPeek = boolean("hide_peek_a_peek") val hideBitmojiPresence = boolean("hide_bitmoji_presence") val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index 8ee08cd21..302103243 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -98,17 +98,18 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C override fun asyncInit() { val stealthMode = context.feature(StealthMode::class) - val hideBitmojiPresence by context.config.messaging.hideBitmojiPresence - val hideTypingNotification by context.config.messaging.hideTypingNotifications - arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook -> Hooker.hook(context.classCache.presenceSession, hook, HookStage.BEFORE, { - hideBitmojiPresence || stealthMode.canUseRule(openedConversationUUID.toString()) + context.config.messaging.hideBitmojiPresence.get() || stealthMode.canUseRule(openedConversationUUID.toString()) }) { it.setResult(null) } } + context.classCache.presenceSession.hook("startPeeking", HookStage.BEFORE, { + context.config.messaging.hidePeekAPeek.get() || stealthMode.canUseRule(openedConversationUUID.toString()) + }) { it.setResult(null) } + //get last opened snap for media downloader context.event.subscribe(OnSnapInteractionEvent::class) { event -> openedConversationUUID = event.conversationId @@ -121,7 +122,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, { - hideTypingNotification || stealthMode.canUseRule(openedConversationUUID.toString()) + context.config.messaging.hideTypingNotifications.get() || stealthMode.canUseRule(openedConversationUUID.toString()) }) { it.setResult(null) } From dc30d4ee254581ba86d69742f5ae5d33cbefcc94 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 14:15:58 +0100 Subject: [PATCH 215/274] feat: half swipe notifier --- common/src/main/assets/lang/en_US.json | 10 ++ .../common/config/impl/MessagingTweaks.kt | 1 + .../features/impl/ConfigurationOverride.kt | 3 +- .../features/impl/messaging/Notifications.kt | 2 +- .../features/impl/spying/HalfSwipeNotifier.kt | 127 ++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 2 + 6 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index de871a4ba..fec12d8eb 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -344,6 +344,10 @@ "name": "Disable Replay in FF", "description": "Disables the ability to replay with a long press from the Friend Feed" }, + "half_swipe_notifier": { + "name": "Half Swipe Notifier", + "description": "Notifies you when someone half swipes into a conversation" + }, "message_preview_length": { "name": "Message Preview Length", "description": "Specify the amount of messages to get previewed" @@ -865,6 +869,12 @@ "dialog_message": "Are you sure you want to start a call?" }, + "half_swipe_notifier": { + "notification_channel_name": "Half Swipe", + "notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds", + "notification_content_group": "{friend} just half-swiped into {group} for {duration} seconds" + }, + "download_processor": { "attachment_type": { "snap": "Snap", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 1e2b53259..c82e3319d 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -12,6 +12,7 @@ class MessagingTweaks : ConfigContainer() { val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") val disableReplayInFF = boolean("disable_replay_in_ff") + val halfSwipeNotifier = boolean("half_swipe_notifier") { requireRestart() } val messagePreviewLength = integer("message_preview_length", defaultValue = 20) val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() } val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt index a34d4ee54..353ebecc0 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt @@ -23,10 +23,11 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea fun getConfigKeyInfo(key: Any?) = runCatching { if (key == null) return@runCatching null val keyClassMethods = key::class.java.methods + val keyName = keyClassMethods.firstOrNull { it.name == "getName" }?.invoke(key)?.toString() ?: key.toString() val category = keyClassMethods.firstOrNull { it.name == enumMappings["getCategory"].toString() }?.invoke(key)?.toString() ?: return null val valueHolder = keyClassMethods.firstOrNull { it.name == enumMappings["getValue"].toString() }?.invoke(key) ?: return null val defaultValue = valueHolder.getObjectField(enumMappings["defaultValueField"].toString()) ?: return null - ConfigKeyInfo(category, key.toString(), defaultValue) + ConfigKeyInfo(category, keyName, defaultValue) }.onFailure { context.log.error("Failed to get config key info", it) }.getOrNull() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index 7c98253ab..f3b5d1f69 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -117,7 +117,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, message: Message, notificationData: NotificationData) { val actions = mutableListOf() - actions.addAll(notificationData.notification.actions) + actions.addAll(notificationData.notification.actions ?: emptyArray()) fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { if (!filter()) return diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt new file mode 100644 index 000000000..19199b908 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt @@ -0,0 +1,127 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds + +class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoadParams.INIT_SYNC) { + private val peekingConversations = ConcurrentHashMap>() + private val startPeekingTimestamps = ConcurrentHashMap() + + private val svgEyeDrawable by lazy { context.resources.getIdentifier("svg_eye_24x24", "drawable") } + private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java) + private val translation by lazy { context.translation.getCategory("half_swipe_notifier")} + private val channelId by lazy { + "peeking".also { + notificationManager.createNotificationChannel( + NotificationChannel( + it, + translation["notification_channel_name"], + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + } + + + override fun init() { + if (!context.config.messaging.halfSwipeNotifier.get()) return + lateinit var presenceService: Any + + findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) { + presenceService = it.thisObject() + } + + PendingIntent::class.java.methods.find { it.name == "getActivity" }?.hook(HookStage.BEFORE) { param -> + context.log.verbose(param.args().toList()) + } + + context.mappings.getMappedClass("callbacks", "PresenceServiceDelegate") + .hook("notifyActiveConversationsChanged", HookStage.BEFORE) { + val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants) + + if (activeConversations.isEmpty()) { + peekingConversations.forEach { + val conversationId = it.key + val peekingParticipantsIds = it.value + peekingParticipantsIds.forEach { userId -> + endPeeking(conversationId, userId) + } + } + peekingConversations.clear() + return@hook + } + + activeConversations.forEach { (conversationId, conversationInfo) -> + val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach + val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList() + + val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet() + val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet() + + newPeekingParticipantsIds.forEach { userId -> + startPeeking(conversationId.toString(), userId) + } + + exitedPeekingParticipantsIds.forEach { userId -> + endPeeking(conversationId.toString(), userId) + } + peekingConversations[conversationId.toString()] = peekingParticipantsIds + } + } + } + + private fun startPeeking(conversationId: String, userId: String) { + startPeekingTimestamps[conversationId + userId] = System.currentTimeMillis() + } + + private fun endPeeking(conversationId: String, userId: String) { + startPeekingTimestamps[conversationId + userId]?.let { startPeekingTimestamp -> + val peekingDuration = (System.currentTimeMillis() - startPeekingTimestamp).milliseconds.inWholeSeconds.toString() + val groupName = context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName + val friendInfo = context.database.getFriendInfo(userId) ?: return + + Notification.Builder(context.androidContext, channelId) + .setContentTitle(groupName ?: friendInfo.displayName ?: friendInfo.mutableUsername) + .setContentText(if (groupName != null) { + translation.format("notification_content_group", + "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), + "group" to groupName, + "duration" to peekingDuration + ) + } else { + translation.format("notification_content_dm", + "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), + "duration" to peekingDuration + ) + }) + .setContentIntent( + context.androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + PendingIntent.getActivity( + context.androidContext, + 0, it, PendingIntent.FLAG_IMMUTABLE + ) + } + ) + .setAutoCancel(true) + .setSmallIcon(svgEyeDrawable) + .build() + .let { notification -> + notificationManager.notify(System.nanoTime().toInt(), notification) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index b6d631b15..09accbc61 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -15,6 +15,7 @@ import me.rhunk.snapenhance.core.features.impl.experiments.* import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.messaging.* import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.core.features.impl.tweaks.BypassScreenshotDetection @@ -105,6 +106,7 @@ class FeatureManager( SnapPreview::class, InstantDelete::class, BypassScreenshotDetection::class, + HalfSwipeNotifier::class, ) initializeFeatures() From 0f1cd7157aa9aa64b9fdd1564794452bcda89b2e Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 18:15:27 +0100 Subject: [PATCH 216/274] feat: randomize package name --- .../me/rhunk/snapenhance/RemoteSideContext.kt | 4 +- .../me/rhunk/snapenhance/common/Constants.kt | 4 +- .../snapenhance/common/ReceiversConfig.kt | 2 +- .../snapenhance/core/bridge/BridgeClient.kt | 6 +- .../snapenhance/core/util/LSPatchUpdater.kt | 4 +- .../snapenhance/manager/data/SharedConfig.kt | 5 +- .../manager/{lspatch => patch}/LSPatch.kt | 33 ++----- .../{lspatch => patch}/LSPatchObfuscation.kt | 61 ++----------- .../snapenhance/manager/patch/Repackager.kt | 89 +++++++++++++++++++ .../{lspatch => patch}/config/Constants.kt | 2 +- .../{lspatch => patch}/config/PatchConfig.kt | 2 +- .../util/ApkSignatureHelper.kt | 23 ++++- .../manager/patch/util/DexLibExt.kt | 63 +++++++++++++ .../snapenhance/manager/ui/MainActivity.kt | 3 +- .../manager/ui/tab/impl/HomeTab.kt | 5 +- .../manager/ui/tab/impl/SettingsTab.kt | 25 ++++-- .../ui/tab/impl/download/LSPatchTab.kt | 2 +- .../ui/tab/impl/download/RepackageTab.kt | 86 ++++++++++++++++++ .../ui/tab/impl/download/SEDownloadTab.kt | 25 +++++- .../ui/tab/impl/download/SnapchatPatchTab.kt | 2 +- 20 files changed, 334 insertions(+), 112 deletions(-) rename manager/src/main/kotlin/me/rhunk/snapenhance/manager/{lspatch => patch}/LSPatch.kt (87%) rename manager/src/main/kotlin/me/rhunk/snapenhance/manager/{lspatch => patch}/LSPatchObfuscation.kt (56%) create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt rename manager/src/main/kotlin/me/rhunk/snapenhance/manager/{lspatch => patch}/config/Constants.kt (82%) rename manager/src/main/kotlin/me/rhunk/snapenhance/manager/{lspatch => patch}/config/PatchConfig.kt (91%) rename manager/src/main/kotlin/me/rhunk/snapenhance/manager/{lspatch => patch}/util/ApkSignatureHelper.kt (84%) create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt create mode 100644 manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 3abd818aa..8461936af 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -126,10 +126,10 @@ class RemoteSideContext( }, modInfo = ModInfo( loaderPackageName = MainActivity::class.java.`package`?.name, - buildPackageName = BuildConfig.APPLICATION_ID, + buildPackageName = androidContext.packageName, buildVersion = BuildConfig.VERSION_NAME, buildVersionCode = BuildConfig.VERSION_CODE.toLong(), - buildIssuer = androidContext.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_SIGNING_CERTIFICATES) + buildIssuer = androidContext.packageManager.getPackageInfo(androidContext.packageName, PackageManager.GET_SIGNING_CERTIFICATES) ?.signingInfo?.apkContentsSigners?.firstOrNull()?.let { val certFactory = CertificateFactory.getInstance("X509") val cert = certFactory.generateCertificate(ByteArrayInputStream(it.toByteArray())) as X509Certificate diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt index 2025fe7ac..26a1451d5 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt @@ -2,8 +2,6 @@ package me.rhunk.snapenhance.common object Constants { val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android" - - val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) - + val SE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt index 253b4af0d..db81a6c15 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ReceiversConfig.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.common object ReceiversConfig { - const val BRIDGE_SYNC_ACTION = BuildConfig.APPLICATION_ID + ".core.bridge.SYNC" + const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.core.bridge.SYNC" const val DOWNLOAD_REQUEST_EXTRA = "request" const val DOWNLOAD_METADATA_EXTRA = "metadata" const val MESSAGING_PREVIEW_EXTRA = "messaging_preview" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt index 4e89aed49..6c5d344ce 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -18,7 +18,7 @@ import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge -import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.bridge.FileLoaderWrapper import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.common.bridge.types.FileActionType @@ -51,7 +51,7 @@ class BridgeClient( with(context.androidContext) { runCatching { startActivity(Intent() - .setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".bridge.ForceStartActivity") + .setClassName(Constants.SE_PACKAGE_NAME, "me.rhunk.snapenhance.bridge.ForceStartActivity") .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) ) } @@ -59,7 +59,7 @@ class BridgeClient( //ensure the remote process is running runCatching { val intent = Intent() - .setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".bridge.BridgeService") + .setClassName(Constants.SE_PACKAGE_NAME,"me.rhunk.snapenhance.bridge.BridgeService") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { bindService( intent, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt index 24a6b03ec..0489cbc52 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/LSPatchUpdater.kt @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.util -import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.bridge.BridgeClient import java.io.File @@ -28,7 +28,7 @@ object LSPatchUpdater { val embeddedModule = context.androidContext.cacheDir .resolve("lspatch") - .resolve(BuildConfig.APPLICATION_ID).let { moduleDir -> + .resolve(Constants.SE_PACKAGE_NAME).let { moduleDir -> if (!moduleDir.exists()) return@let null moduleDir.listFiles()?.firstOrNull { it.extension == "apk" } } ?: obfuscatedModulePath?.let { path -> diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt index 815b37a44..05d82240d 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/data/SharedConfig.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.manager.data import android.content.Context +import me.rhunk.snapenhance.manager.BuildConfig class SharedConfig( context: Context @@ -16,8 +17,10 @@ class SharedConfig( var snapchatPackageName get() = sharedPreferences.getString("snapchatPackageName", "com.snapchat.android")?.takeIf { it.isNotEmpty() } ?: "com.snapchat.android" set(value) = sharedPreferences.edit().putString("snapchatPackageName", value).apply() - var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", "me.rhunk.snapenhance")?.takeIf { it.isNotEmpty() } ?: "me.rhunk.snapenhance" + var snapEnhancePackageName get() = sharedPreferences.getString("snapEnhancePackageName", BuildConfig.APPLICATION_ID)?.takeIf { it.isNotEmpty() } ?: BuildConfig.APPLICATION_ID set(value) = sharedPreferences.edit().putString("snapEnhancePackageName", value).apply() + var enableRepackage get() = sharedPreferences.getBoolean("enableRepackage", false) + set(value) = sharedPreferences.edit().putBoolean("enableRepackage", value).apply() var useRootInstaller get() = sharedPreferences.getBoolean("useRootInstaller", false) set(value) = sharedPreferences.edit().putBoolean("useRootInstaller", value).apply() diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt similarity index 87% rename from manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt rename to manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt index 903cdf7ad..9c316d4d9 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatch.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatch.kt @@ -1,8 +1,6 @@ -package me.rhunk.snapenhance.manager.lspatch +package me.rhunk.snapenhance.manager.patch import android.content.Context -import com.android.tools.build.apkzlib.sign.SigningExtension -import com.android.tools.build.apkzlib.sign.SigningOptions import com.android.tools.build.apkzlib.zip.AlignmentRules import com.android.tools.build.apkzlib.zip.ZFile import com.android.tools.build.apkzlib.zip.ZFileOptions @@ -10,14 +8,13 @@ import com.google.gson.Gson import com.wind.meditor.core.ManifestEditor import com.wind.meditor.property.AttributeItem import com.wind.meditor.property.ModificationProperty -import me.rhunk.snapenhance.manager.lspatch.config.Constants.PROXY_APP_COMPONENT_FACTORY -import me.rhunk.snapenhance.manager.lspatch.config.PatchConfig -import me.rhunk.snapenhance.manager.lspatch.util.ApkSignatureHelper +import me.rhunk.snapenhance.manager.patch.config.Constants.PROXY_APP_COMPONENT_FACTORY +import me.rhunk.snapenhance.manager.patch.config.PatchConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File -import java.security.KeyStore -import java.security.cert.X509Certificate import java.util.zip.ZipFile import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -45,22 +42,6 @@ class LSPatch( }.toByteArray() } - private fun provideSigningExtension(): SigningExtension { - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(context.assets.open("lspatch/keystore.jks"), "android".toCharArray()) - val key = keyStore.getEntry("androiddebugkey", KeyStore.PasswordProtection("android".toCharArray())) as KeyStore.PrivateKeyEntry - val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() - - return SigningExtension( - SigningOptions.builder().apply { - setMinSdkVersion(28) - setV2SigningEnabled(true) - setCertificates(*certificates) - setKey(key.privateKey) - }.build() - ) - } - private fun resignApk(inputApkFile: File, outputFile: File) { printLog("Resigning ${inputApkFile.absolutePath} to ${outputFile.absolutePath}") val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions()) @@ -72,7 +53,7 @@ class LSPatch( // sign apk runCatching { - provideSigningExtension().register(dstZFile) + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) }.onFailure { throw Exception("Failed to sign apk", it) } @@ -131,7 +112,7 @@ class LSPatch( // sign apk runCatching { - provideSigningExtension().register(dstZFile) + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) }.onFailure { throw Exception("Failed to sign apk", it) } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt similarity index 56% rename from manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt rename to manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt index d478e2c8c..237f1b5ef 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/LSPatchObfuscation.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/LSPatchObfuscation.kt @@ -1,12 +1,6 @@ -package me.rhunk.snapenhance.manager.lspatch +package me.rhunk.snapenhance.manager.patch -import org.jf.dexlib2.Opcodes -import org.jf.dexlib2.dexbacked.DexBackedDexFile -import org.jf.dexlib2.iface.reference.StringReference -import org.jf.dexlib2.writer.io.FileDataStore -import org.jf.dexlib2.writer.pool.DexPool -import org.jf.dexlib2.writer.pool.StringPool -import java.io.BufferedInputStream +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile import java.io.File import java.io.InputStream @@ -27,52 +21,9 @@ class LSPatchObfuscation( private val cacheFolder: File, private val printLog: (String) -> Unit = { println(it) } ) { - private fun obfuscateDexFile(dexStrings: Map, inputStream: InputStream): File { - val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(inputStream)) - - val dexPool = object: DexPool(dexFile.opcodes) { - override fun getSectionProvider(): SectionProvider { - val dexPool = this - return object: DexPoolSectionProvider() { - override fun getStringSection() = object: StringPool(dexPool) { - private val cacheMap = mutableMapOf() - - override fun intern(string: CharSequence) { - dexStrings[string.toString()]?.let { - cacheMap[string.toString()] = it - printLog("mapping $string to $it") - super.intern(it) - return - } - super.intern(string) - } - - override fun getItemIndex(key: CharSequence): Int { - return cacheMap[key.toString()]?.let { - internedItems[it] - } ?: super.getItemIndex(key) - } - - override fun getItemIndex(key: StringReference): Int { - return cacheMap[key.toString()]?.let { - internedItems[it] - } ?: super.getItemIndex(key) - } - } - } - } - } - dexFile.classes.forEach { dexBackedClassDef -> - dexPool.internClass(dexBackedClassDef) - } - val outputFile = File.createTempFile("obf", ".dex", cacheFolder) - dexPool.writeTo(FileDataStore(outputFile)) - return outputFile - } - fun obfuscateMetaLoader(inputStream: InputStream, config: DexObfuscationConfig): File { - return obfuscateDexFile(mapOf( + return inputStream.obfuscateDexFile(cacheFolder, mapOf( "assets/lspatch/config.json" to "assets/${config.configFilePath}", "assets/lspatch/loader.dex" to "assets/${config.loaderFilePath}", ) + (config.libNativeFilePath.takeIf { it.isNotEmpty() }?.let { @@ -85,11 +36,11 @@ class LSPatchObfuscation( "x86" to config.libNativeFilePath["x86"], "x86_64" to config.libNativeFilePath["x86_64"], ) - } ?: mapOf()), inputStream) + } ?: mapOf())) } fun obfuscateLoader(inputStream: InputStream, config: DexObfuscationConfig): File { - return obfuscateDexFile(mapOf( + return inputStream.obfuscateDexFile(cacheFolder, mapOf( "assets/lspatch/config.json" to config.configFilePath?.let { "assets/$it" }, "assets/lspatch/loader.dex" to config.loaderFilePath?.let { "assets/$it" }, "assets/lspatch/metaloader.dex" to config.metaLoaderFilePath?.let { "assets/$it" }, @@ -102,6 +53,6 @@ class LSPatchObfuscation( "lspatch/modules/" to config.assetModuleFolderPath?.let { "$it/" }, // LocalApplicationService.java => try (var is = context.getAssets().open("lspatch/modules/" + name)) { "lspatch" to config.metadataManifestField, // SigBypass.java => "lspatch", "org.lsposed.lspatch" to config.cachedOriginApkPath?.let { "$it/${config.packageName}/" }, // Constants.java => "org.lsposed.lspatch", (Used in LSPatchUpdater.kt) - ), inputStream) + )) } } \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt new file mode 100644 index 000000000..52157d6de --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/Repackager.kt @@ -0,0 +1,89 @@ +package me.rhunk.snapenhance.manager.patch + +import android.content.Context +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +import com.wind.meditor.core.ManifestEditor +import com.wind.meditor.property.AttributeItem +import com.wind.meditor.property.ModificationProperty +import me.rhunk.snapenhance.manager.BuildConfig +import me.rhunk.snapenhance.manager.patch.util.ApkSignatureHelper.provideSigningExtension +import me.rhunk.snapenhance.manager.patch.util.obfuscateDexFile +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File + +class Repackager( + private val context: Context, + private val cacheFolder: File, + private val packageName: String, +) { + private fun patchManifest(data: ByteArray): ByteArray { + val property = ModificationProperty() + + property.addManifestAttribute(AttributeItem("package", packageName).apply { + type = 3 + namespace = null + }) + + return ByteArrayOutputStream().apply { + ManifestEditor(ByteArrayInputStream(data), this, property).processManifest() + flush() + close() + }.toByteArray() + } + + fun patch(apkFile: File): File { + val outputFile = File(cacheFolder, "patched-${apkFile.name}") + runCatching { + patch(apkFile, outputFile) + }.onFailure { + outputFile.delete() + throw it + } + return outputFile + } + + fun patch(apkFile: File, outputFile: File) { + val dstZFile = ZFile.openReadWrite(outputFile, ZFileOptions().setAlignmentRule( + AlignmentRules.compose(AlignmentRules.constantForSuffix(".so", 4096)) + )) + provideSigningExtension(context.assets.open("lspatch/keystore.jks")).register(dstZFile) + val srcZFile = ZFile.openReadOnly(apkFile) + val dexFiles = mutableListOf() + + for (entry in srcZFile.entries()) { + val name = entry.centralDirectoryHeader.name + if (name.startsWith("AndroidManifest.xml")) { + dstZFile.add(name, ByteArrayInputStream( + patchManifest(entry.read()) + ), false) + continue + } + if (name.startsWith("classes") && name.endsWith(".dex")) { + println("obfuscating $name") + val inputStream = entry.open() ?: continue + val obfuscatedDexFile = inputStream.obfuscateDexFile(cacheFolder, { dexFile -> + dexFile.classes.firstOrNull { it.type == "Lme/rhunk/snapenhance/common/Constants;" } != null + }, mapOf( + BuildConfig.APPLICATION_ID to packageName + ))?.also { dexFiles.add(it) } + + if (obfuscatedDexFile == null) { + inputStream.close() + dstZFile.add(name, entry.open(), false) + continue + } + + dstZFile.add(name, obfuscatedDexFile.inputStream(), false) + continue + } + dstZFile.add(name, entry.open(), false) + } + dstZFile.realign() + dstZFile.close() + srcZFile.close() + dexFiles.forEach { it.delete() } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt similarity index 82% rename from manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt rename to manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt index adcd943ae..80e30f32f 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/Constants.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/Constants.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.manager.lspatch.config +package me.rhunk.snapenhance.manager.patch.config //https://github.com/LSPosed/LSPatch/blob/master/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java object Constants { diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt similarity index 91% rename from manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt rename to manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt index 358fe02d9..ea06cf83a 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/config/PatchConfig.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/config/PatchConfig.kt @@ -1,4 +1,4 @@ -package me.rhunk.snapenhance.manager.lspatch.config +package me.rhunk.snapenhance.manager.patch.config data class PatchConfig( val useManager: Boolean = false, diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt similarity index 84% rename from manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt rename to manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt index a1c628fe8..b83407313 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/lspatch/util/ApkSignatureHelper.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt @@ -1,11 +1,16 @@ -package me.rhunk.snapenhance.manager.lspatch.util; +package me.rhunk.snapenhance.manager.patch.util; +import com.android.tools.build.apkzlib.sign.SigningExtension +import com.android.tools.build.apkzlib.sign.SigningOptions import java.io.IOException +import java.io.InputStream import java.io.RandomAccessFile import java.io.UnsupportedEncodingException import java.nio.ByteBuffer import java.nio.ByteOrder +import java.security.KeyStore import java.security.cert.Certificate +import java.security.cert.X509Certificate import java.util.Enumeration import java.util.jar.JarEntry import java.util.jar.JarFile @@ -16,6 +21,22 @@ object ApkSignatureHelper { private val APK_V2_MAGIC = charArrayOf('A', 'P', 'K', ' ', 'S', 'i', 'g', ' ', 'B', 'l', 'o', 'c', 'k', ' ', '4', '2') + fun provideSigningExtension(keyStoreInputStream: InputStream): SigningExtension { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(keyStoreInputStream, "android".toCharArray()) + val key = keyStore.getEntry("androiddebugkey", KeyStore.PasswordProtection("android".toCharArray())) as KeyStore.PrivateKeyEntry + val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() + + return SigningExtension( + SigningOptions.builder().apply { + setMinSdkVersion(28) + setV2SigningEnabled(true) + setCertificates(*certificates) + setKey(key.privateKey) + }.build() + ) + } + private fun toChars(mSignature: ByteArray): CharArray { val N = mSignature.size val N2 = N * 2 diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt new file mode 100644 index 000000000..97f01a0b4 --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/DexLibExt.kt @@ -0,0 +1,63 @@ +package me.rhunk.snapenhance.manager.patch.util + +import org.jf.dexlib2.Opcodes +import org.jf.dexlib2.dexbacked.DexBackedDexFile +import org.jf.dexlib2.iface.DexFile +import org.jf.dexlib2.iface.reference.StringReference +import org.jf.dexlib2.writer.io.FileDataStore +import org.jf.dexlib2.writer.pool.DexPool +import org.jf.dexlib2.writer.pool.StringPool +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream + + +private fun obfuscateStrings(dexFile: DexFile, dexStrings: Map): DexPool { + val dexPool = object: DexPool(dexFile.opcodes) { + override fun getSectionProvider(): SectionProvider { + val dexPool = this + return object: DexPoolSectionProvider() { + override fun getStringSection() = object: StringPool(dexPool) { + private val cacheMap = mutableMapOf() + + override fun intern(string: CharSequence) { + dexStrings[string.toString()]?.let { + cacheMap[string.toString()] = it + println("mapping $string to $it") + super.intern(it) + return + } + super.intern(string) + } + + override fun getItemIndex(key: CharSequence): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + + override fun getItemIndex(key: StringReference): Int { + return cacheMap[key.toString()]?.let { + internedItems[it] + } ?: super.getItemIndex(key) + } + } + } + } + } + dexFile.classes.forEach { dexBackedClassDef -> + dexPool.internClass(dexBackedClassDef) + } + return dexPool +} + +fun InputStream.obfuscateDexFile(cacheFolder: File, dexStrings: Map) + = this.obfuscateDexFile(cacheFolder, { true }, dexStrings)!! + +fun InputStream.obfuscateDexFile(cacheFolder: File, filter: (DexFile) -> Boolean, dexStrings: Map): File? { + val dexFile = DexBackedDexFile.fromInputStream(Opcodes.forApi(29), BufferedInputStream(this)) + if (!filter(dexFile)) return null + val outputFile = File.createTempFile("dexobf", ".dex", cacheFolder) + obfuscateStrings(dexFile, dexStrings).writeTo(FileDataStore(outputFile)) + return outputFile +} diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt index 2bc3b8bd1..e87e771c4 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/MainActivity.kt @@ -16,10 +16,11 @@ import me.rhunk.snapenhance.manager.ui.tab.Tab import me.rhunk.snapenhance.manager.ui.tab.impl.HomeTab import me.rhunk.snapenhance.manager.ui.tab.impl.SettingsTab import me.rhunk.snapenhance.manager.ui.tab.impl.download.InstallPackageTab +import me.rhunk.snapenhance.manager.ui.tab.impl.download.RepackageTab class MainActivity : ComponentActivity() { companion object{ - private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class) + private val primaryTabs = listOf(HomeTab::class, SettingsTab::class, InstallPackageTab::class, RepackageTab::class) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt index 7d69f149f..5eb07736b 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/HomeTab.kt @@ -10,10 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.patch.config.Constants import me.rhunk.snapenhance.manager.ui.tab.Tab import me.rhunk.snapenhance.manager.ui.tab.impl.download.SEDownloadTab import me.rhunk.snapenhance.manager.ui.tab.impl.download.SnapchatPatchTab @@ -66,6 +66,7 @@ class HomeTab : Tab("home", true, icon = Icons.Default.Home) { Text(text = "SnapEnhance", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) snapEnhanceInfo?.let { Text(text = "${it.versionName} (${it.longVersionCode}) - ${if ((it.applicationInfo.flags and FLAG_DEBUGGABLE) != 0) "Debug" else "Release"}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(it.packageName, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } Row( diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt index 30badeb17..e153edacf 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/SettingsTab.kt @@ -22,10 +22,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import me.rhunk.snapenhance.manager.ui.tab.Tab +import kotlin.random.Random class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Settings) { @Composable - private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String) { + private fun ConfigEditRow(getValue: () -> String?, setValue: (String) -> Unit, label: String, randomValueProvider: (() -> String)? = null) { var showDialog by remember { mutableStateOf(false) } if (showDialog) { @@ -65,6 +66,13 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti }) { Text(text = "Cancel") } + if (randomValueProvider != null) { + Button(onClick = { + textFieldValue = TextFieldValue(randomValueProvider(), TextRange(0)) + }) { + Text(text = "Random") + } + } Button(onClick = { setValue(textFieldValue.text) showDialog = false @@ -130,15 +138,18 @@ class SettingsTab : Tab("settings", isPrimary = true, icon = Icons.Default.Setti override fun Content() { Column { Spacer(modifier = Modifier.height(16.dp)) - ConfigEditRow( - getValue = { sharedConfig.snapchatPackageName }, - setValue = { sharedConfig.snapchatPackageName = it }, - label = "Override Snapchat package name" - ) ConfigEditRow( getValue = { sharedConfig.snapEnhancePackageName }, setValue = { sharedConfig.snapEnhancePackageName = it }, - label = "Override SnapEnhance package name" + label = "Override SnapEnhance package name", + randomValueProvider = { + (0..Random.nextInt(7, 16)).map { ('a'..'z').random() }.joinToString("").chunked(4).joinToString(".") + } + ) + ConfigBooleanRow( + getValue = { sharedConfig.enableRepackage }, + setValue = { sharedConfig.enableRepackage = it }, + label = "Repackage SnapEnhance (experimental)" ) ConfigBooleanRow( getValue = { sharedConfig.useRootInstaller }, diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt index 3d26eff71..b5b8afd96 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.lspatch.LSPatch +import me.rhunk.snapenhance.manager.patch.LSPatch import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog import me.rhunk.snapenhance.manager.ui.tab.Tab import okio.use diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt new file mode 100644 index 000000000..c34c5f17e --- /dev/null +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/RepackageTab.kt @@ -0,0 +1,86 @@ +package me.rhunk.snapenhance.manager.ui.tab.impl.download + +import android.os.Bundle +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.manager.patch.Repackager +import me.rhunk.snapenhance.manager.ui.tab.Tab +import java.io.File + +enum class RepackageState { + IDLE, + WORKING, + SUCCESS, + FAILED +} + +class RepackageTab : Tab("repackage") { + private var throwable: Throwable? = null + + private suspend fun repackage(apk: File, oldPackage: String, state: MutableState) { + state.value = RepackageState.WORKING + val repackager = Repackager(activity, activity.externalCacheDirs.first(), sharedConfig.snapEnhancePackageName) + + runCatching { + repackager.patch(apk) + }.onFailure { + throwable = it + state.value = RepackageState.FAILED + return + }.onSuccess { originApk -> + state.value = RepackageState.SUCCESS + + withContext(Dispatchers.Main) { + navigation.navigateTo(InstallPackageTab::class, Bundle().apply { + putString("downloadPath", originApk.absolutePath) + putString("appPackage", oldPackage) + putBoolean("uninstall", true) + }, noHistory = true) + } + + return + } + } + + @Composable + override fun Content() { + val apkPath = remember { getArguments()?.getString("apkPath") } ?: return + val oldPackage = remember { getArguments()?.getString("oldPackage") } ?: return + val state = remember { mutableStateOf(RepackageState.IDLE) } + + LaunchedEffect(apkPath) { + launch(Dispatchers.IO) { + repackage(File(apkPath), oldPackage, state) + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + when (state.value) { + RepackageState.WORKING -> Text(text = "Repackaging ...") + RepackageState.FAILED -> { + Text(text = "Failed") + Text(text = (throwable?.localizedMessage + throwable?.stackTraceToString())) + } + else -> {} + } + } + } +} \ No newline at end of file diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt index 3343a312a..4d047d205 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SEDownloadTab.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.window.Dialog import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import me.rhunk.snapenhance.manager.BuildConfig import me.rhunk.snapenhance.manager.data.download.SEArtifact import me.rhunk.snapenhance.manager.data.download.SEVersion import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog @@ -82,7 +83,9 @@ class SEDownloadTab : Tab("se_download") { var selectedVersion by remember { mutableStateOf(null as SEVersion?) } var selectedArtifact by remember { mutableStateOf(null as SEArtifact?) } - val isAppInstalled = remember { runCatching { activity.packageManager.getPackageInfo(sharedConfig.snapEnhancePackageName, 0) != null }.getOrNull() != null } + val snapEnhanceApp = remember { + runCatching { activity.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, 0) }.getOrNull() + } var showDowngradeNotice by remember { mutableStateOf(false) } @@ -209,7 +212,21 @@ class SEDownloadTab : Tab("se_download") { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (isAppInstalled) { + if (snapEnhanceApp != null) { + if (sharedConfig.enableRepackage && sharedConfig.snapEnhancePackageName != snapEnhanceApp.packageName) { + Button( + onClick = { + navigation.navigateTo(RepackageTab::class, Bundle().apply { + putString("apkPath", snapEnhanceApp.applicationInfo.sourceDir) + putString("oldPackage", snapEnhanceApp.packageName) + }, noHistory = true) + }, + enabled = true, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Repackage installed version (>=2.0.0)") + } + } Button( onClick = { triggerPackageInstallation(true) @@ -222,7 +239,7 @@ class SEDownloadTab : Tab("se_download") { } Button( onClick = { - if (isAppInstalled) { + if (snapEnhanceApp != null) { showDowngradeNotice = true } else { triggerPackageInstallation(false) @@ -231,7 +248,7 @@ class SEDownloadTab : Tab("se_download") { enabled = selectedVersion != null && selectedArtifact != null, modifier = Modifier.fillMaxWidth() ) { - Text(text = if (isAppInstalled) "Update" else "Install") + Text(text = if (snapEnhanceApp != null) "Update" else "Install") } } } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt index 46d63f8df..f452ca55d 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext import me.rhunk.snapenhance.manager.R import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem -import me.rhunk.snapenhance.manager.lspatch.config.Constants +import me.rhunk.snapenhance.manager.patch.config.Constants import me.rhunk.snapenhance.manager.ui.components.ConfirmationDialog import me.rhunk.snapenhance.manager.ui.tab.Tab import java.io.File From da8561cddb1543af1bfeaa5f6f2a4ee22df94444 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 23:09:37 +0100 Subject: [PATCH 217/274] build(manager): proguard rules --- manager/proguard-rules.pro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/manager/proguard-rules.pro b/manager/proguard-rules.pro index 6e9225ade..71e979c34 100644 --- a/manager/proguard-rules.pro +++ b/manager/proguard-rules.pro @@ -1,3 +1,5 @@ -dontwarn com.google.errorprone.annotations.** -dontwarn com.google.auto.value.** +-keep enum * { *; } +-keep class org.jf.dexlib2.** { *; } -keep class me.rhunk.snapenhance.manager.ui.tab.** { *; } \ No newline at end of file From 96183921dc5febaa4bbbfdea936f08f038e6c0a6 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 23:14:42 +0100 Subject: [PATCH 218/274] perf(core): database access --- .../core/database/DatabaseAccess.kt | 81 ++++----- .../rhunk/snapenhance/core/event/EventBus.kt | 14 +- .../impl/ui/FriendFeedMessagePreview.kt | 160 +++++++++++------- 3 files changed, 149 insertions(+), 106 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 43c37e757..9304a435d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.core.database import android.database.Cursor import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteDatabase.OpenParams import android.database.sqlite.SQLiteDatabaseCorruptException import me.rhunk.snapenhance.common.database.DatabaseObject import me.rhunk.snapenhance.common.database.impl.ConversationMessage @@ -9,12 +10,11 @@ import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.StoryEntry import me.rhunk.snapenhance.common.database.impl.UserConversationLink +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getInteger import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.manager.Manager -import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import java.io.File class DatabaseAccess( @@ -25,51 +25,32 @@ class DatabaseAccess( private inline fun SQLiteDatabase.performOperation(crossinline query: SQLiteDatabase.() -> T?): T? { return runCatching { - query() + synchronized(this) { + query() + } }.onFailure { context.log.error("Database operation failed", it) }.getOrNull() } - private var hasShownDatabaseError = false - - private fun showDatabaseError(databasePath: String, throwable: Throwable) { - if (hasShownDatabaseError) return - hasShownDatabaseError = true - context.runOnUiThread { - if (context.mainActivity == null) return@runOnUiThread - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle("SnapEnhance") - .setMessage("Failed to query $databasePath database!\n\n${throwable.localizedMessage}\n\nRestarting Snapchat may fix this issue. If the issue persists, try to clean the app data and cache.") - .setPositiveButton("Restart Snapchat") { _, _ -> - File(databasePath).takeIf { it.exists() }?.delete() - context.softRestartApp() - } - .setNegativeButton("Dismiss") { dialog, _ -> - dialog.dismiss() - }.show() - } - } - private fun SQLiteDatabase.safeRawQuery(query: String, args: Array? = null): Cursor? { return runCatching { rawQuery(query, args) }.onFailure { if (it !is SQLiteDatabaseCorruptException) { context.log.error("Failed to execute query $query", it) - showDatabaseError(this.path, it) return@onFailure } - context.log.warn("Database ${this.path} is corrupted!") + context.longToast("Database ${this.path} is corrupted! Restarting ...") context.androidContext.deleteDatabase(this.path) - showDatabaseError(this.path, it) + context.crash("Database ${this.path} is corrupted!", it) }.getOrNull() } private val dmOtherParticipantCache by lazy { (arroyoDb?.performOperation { safeRawQuery( - "SELECT client_conversation_id, user_id FROM user_conversation WHERE conversation_type = 0 AND user_id != ?", + "SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?", arrayOf(myUserId) )?.use { query -> val participants = mutableMapOf() @@ -77,7 +58,13 @@ class DatabaseAccess( return@performOperation null } do { - participants[query.getStringOrNull("client_conversation_id")!!] = query.getStringOrNull("user_id")!! + val conversationId = query.getStringOrNull("client_conversation_id") ?: continue + val userId = query.getStringOrNull("user_id") ?: continue + participants[conversationId] = when (query.getIntOrNull("conversation_type")) { + 0 -> userId + else -> null + } + participants[userId] = null } while (query.moveToNext()) participants } @@ -89,13 +76,16 @@ class DatabaseAccess( if (!dbPath.exists()) return null return runCatching { SQLiteDatabase.openDatabase( - dbPath.absolutePath, - null, - SQLiteDatabase.OPEN_READONLY or SQLiteDatabase.NO_LOCALIZED_COLLATORS + dbPath, + OpenParams.Builder() + .setOpenFlags(SQLiteDatabase.OPEN_READONLY) + .setErrorHandler { + context.androidContext.deleteDatabase(dbPath.absolutePath) + context.softRestartApp() + }.build() ) }.onFailure { context.log.error("Failed to open database $fileName!", it) - showDatabaseError(dbPath.absolutePath, it) }.getOrNull() } @@ -137,6 +127,7 @@ class DatabaseAccess( } val myUserId by lazy { + context.androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null) ?: arroyoDb?.performOperation { safeRawQuery(buildString { append("SELECT value FROM required_values WHERE key = 'USERID'") @@ -146,7 +137,7 @@ class DatabaseAccess( } query.getStringOrNull("value")!! } - } ?: context.androidContext.getSharedPreferences("user_session_shared_pref", 0).getString("key_user_id", null)!! + }!! } fun getFeedEntryByConversationId(conversationId: String): FriendFeedEntry? { @@ -241,8 +232,8 @@ class DatabaseAccess( participants.add(query.getStringOrNull("user_id")!!) } while (query.moveToNext()) participants.firstOrNull { it != myUserId } - } - }.also { dmOtherParticipantCache[conversationId] = it } + }.also { dmOtherParticipantCache[conversationId] = it } + } } @@ -253,18 +244,28 @@ class DatabaseAccess( } fun getConversationParticipants(conversationId: String): List? { + if (dmOtherParticipantCache[conversationId] != null) return dmOtherParticipantCache[conversationId]?.let { listOf(myUserId, it) } return arroyoDb?.performOperation { safeRawQuery( - "SELECT user_id FROM user_conversation WHERE client_conversation_id = ?", + "SELECT user_id, conversation_type FROM user_conversation WHERE client_conversation_id = ?", arrayOf(conversationId) - )?.use { - if (!it.moveToFirst()) { + )?.use { cursor -> + if (!cursor.moveToFirst()) { return@performOperation null } val participants = mutableListOf() + var conversationType = -1 do { - participants.add(it.getStringOrNull("user_id")!!) - } while (it.moveToNext()) + if (conversationType == -1) conversationType = cursor.getInteger("conversation_type") + participants.add(cursor.getStringOrNull("user_id")!!) + } while (cursor.moveToNext()) + + if (!dmOtherParticipantCache.containsKey(conversationId)) { + dmOtherParticipantCache[conversationId] = when (conversationType) { + 0 -> participants.firstOrNull { it != myUserId } + else -> null + } + } participants } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt index 9c08f40d9..6c233452f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventBus.kt @@ -18,11 +18,13 @@ class EventBus( private val subscribers = mutableMapOf, MutableMap>>() fun subscribe(event: KClass, listener: IListener, priority: Int? = null) { - if (!subscribers.containsKey(event)) { - subscribers[event] = sortedMapOf() + synchronized(subscribers) { + if (!subscribers.containsKey(event)) { + subscribers[event] = sortedMapOf() + } + val lastSubscriber = subscribers[event]?.keys?.lastOrNull() ?: 0 + subscribers[event]?.put(priority ?: (lastSubscriber + 1), listener) } - val lastSubscriber = subscribers[event]?.keys?.lastOrNull() ?: 0 - subscribers[event]?.put(priority ?: (lastSubscriber + 1), listener) } inline fun subscribe(event: KClass, priority: Int? = null, crossinline listener: (T) -> Unit) = subscribe(event, { true }, priority, listener) @@ -43,7 +45,9 @@ class EventBus( } fun unsubscribe(event: KClass, listener: IListener) { - subscribers[event]?.values?.remove(listener) + synchronized(subscribers) { + subscribers[event]?.values?.remove(listener) + } } fun post(event: T, afterBlock: T.() -> Unit = {}): T? { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt index 9671a4db0..cd7f89973 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.features.impl.ui -import android.annotation.SuppressLint import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect @@ -9,9 +8,14 @@ import android.graphics.drawable.shapes.Shape import android.text.TextPaint import android.view.View import android.view.ViewGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption @@ -21,25 +25,65 @@ import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getId import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import java.util.WeakHashMap import kotlin.math.absoluteValue -@SuppressLint("DiscouragedApi") class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val setting get() = context.config.userInterface.friendFeedMessagePreview + private val hasE2EE get() = context.config.experimental.e2eEncryption.globalState == true + private val sigColorTextPrimary by lazy { context.mainActivity!!.theme.obtainStyledAttributes( intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) ).getColor(0, 0) } + private val cachedLayouts = WeakHashMap() + private val messageCache = EvictingMap>(100) private val friendNameCache = EvictingMap(100) + private suspend fun fetchMessages(conversationId: String, callback: suspend () -> Unit) { + val messages = context.database.getMessagesFromConversationId(conversationId, setting.amount.get().absoluteValue)?.mapNotNull { message -> + val messageContainer = + message.messageContent + ?.let { ProtoReader(it) } + ?.followPath(4, 4)?.let { messageReader -> + takeIf { hasE2EE }?.let takeIf@{ + endToEndEncryption.tryDecryptMessage( + senderId = message.senderId ?: return@takeIf null, + clientMessageId = message.clientMessageId.toLong(), + conversationId = message.clientConversationId ?: return@takeIf null, + contentType = ContentType.fromId(message.contentType), + messageBuffer = messageReader.getBuffer() + ).second + }?.let { ProtoReader(it) } ?: messageReader + } + ?: return@mapNotNull null + + val messageString = messageContainer.getString(2, 1) + ?: ContentType.fromMessageContainer(messageContainer)?.name + ?: return@mapNotNull null + + val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { + context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + } + "$friendName: $messageString" + }?.takeIf { it.isNotEmpty() }?.reversed() + + withContext(Dispatchers.Main) { + messages?.also { messageCache[conversationId] = it } ?: run { + messageCache.remove(conversationId) + } + callback() + } + } + override fun onActivityCreate() { - val setting = context.config.userInterface.friendFeedMessagePreview if (setting.globalState != true) return - val hasE2EE = context.config.experimental.e2eEncryption.globalState == true - val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } - val ffItemId = context.resources.getId("ff_item") val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat() @@ -54,71 +98,65 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams textSize = secondaryTextSize } + context.event.subscribe(BuildMessageEvent::class) { param -> + val conversationId = param.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val cachedView = cachedLayouts[conversationId] ?: return@subscribe + context.coroutineScope.launch { + fetchMessages(conversationId) { + cachedView.postInvalidateDelayed(100L) + } + } + } + context.event.subscribe(BindViewEvent::class) { param -> param.friendFeedItem { conversationId -> val frameLayout = param.view as ViewGroup val ffItem = frameLayout.findViewById(ffItemId) - ffItem.layoutParams = ffItem.layoutParams.apply { - height = ViewGroup.LayoutParams.MATCH_PARENT - } - frameLayout.removeForegroundDrawable("ffItem") - - val stringMessages = context.database.getMessagesFromConversationId(conversationId, setting.amount.get().absoluteValue)?.mapNotNull { message -> - val messageContainer = - message.messageContent - ?.let { ProtoReader(it) } - ?.followPath(4, 4)?.let { messageReader -> - takeIf { hasE2EE }?.let takeIf@{ - endToEndEncryption.tryDecryptMessage( - senderId = message.senderId ?: return@takeIf null, - clientMessageId = message.clientMessageId.toLong(), - conversationId = message.clientConversationId ?: return@takeIf null, - contentType = ContentType.fromId(message.contentType), - messageBuffer = messageReader.getBuffer() - ).second - }?.let { ProtoReader(it) } ?: messageReader - } - ?: return@mapNotNull null - - val messageString = messageContainer.getString(2, 1) - ?: ContentType.fromMessageContainer(messageContainer)?.name - ?: return@mapNotNull null - - val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { - context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + context.coroutineScope.launch(coroutineDispatcher) { + withContext(Dispatchers.Main) { + cachedLayouts.remove(conversationId) + frameLayout.removeForegroundDrawable("ffItem") } - "$friendName: $messageString" - }?.reversed() ?: return@friendFeedItem - - var maxTextHeight = 0 - val previewContainerHeight = stringMessages.sumOf { msg -> - val rect = Rect() - textPaint.getTextBounds(msg, 0, msg.length, rect) - rect.height().also { - if (it > maxTextHeight) maxTextHeight = it - }.plus(separatorHeight) - } - ffItem.layoutParams = ffItem.layoutParams.apply { - height = feedEntryHeight + previewContainerHeight + separatorHeight - } + fetchMessages(conversationId) { + var maxTextHeight = 0 + val previewContainerHeight = messageCache[conversationId]?.sumOf { msg -> + val rect = Rect() + textPaint.getTextBounds(msg, 0, msg.length, rect) + rect.height().also { + if (it > maxTextHeight) maxTextHeight = it + }.plus(separatorHeight) + } ?: run { + ffItem.layoutParams = ffItem.layoutParams.apply { + height = ViewGroup.LayoutParams.MATCH_PARENT + } + return@fetchMessages + } - frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { - override fun draw(canvas: Canvas, paint: Paint) { - val offsetY = canvas.height.toFloat() - previewContainerHeight - - stringMessages.forEachIndexed { index, messageString -> - paint.textSize = secondaryTextSize - paint.color = sigColorTextPrimary - canvas.drawText(messageString, - feedEntryHeight + ffSdlPrimaryTextStartMargin, - offsetY + index * maxTextHeight, - paint - ) + ffItem.layoutParams = ffItem.layoutParams.apply { + height = feedEntryHeight + previewContainerHeight + separatorHeight } + + cachedLayouts[conversationId] = frameLayout + + frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val offsetY = canvas.height.toFloat() - previewContainerHeight + + messageCache[conversationId]?.forEachIndexed { index, messageString -> + paint.textSize = secondaryTextSize + paint.color = sigColorTextPrimary + canvas.drawText(messageString, + feedEntryHeight + ffSdlPrimaryTextStartMargin, + offsetY + index * maxTextHeight, + paint + ) + } + } + })) } - })) + } } } } From ff79f2009d7ac6ccde0c28dc9af48fb9fd050f70 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 23:35:35 +0100 Subject: [PATCH 219/274] refactor(core): unused code --- .../core/features/impl/spying/HalfSwipeNotifier.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt index 19199b908..90f17c127 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt @@ -43,10 +43,6 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoa presenceService = it.thisObject() } - PendingIntent::class.java.methods.find { it.name == "getActivity" }?.hook(HookStage.BEFORE) { param -> - context.log.verbose(param.args().toList()) - } - context.mappings.getMappedClass("callbacks", "PresenceServiceDelegate") .hook("notifyActiveConversationsChanged", HookStage.BEFORE) { val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants) From 4b926f199c9af38227a5e2123774b8d5b020b766 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 11 Nov 2023 23:43:15 +0100 Subject: [PATCH 220/274] fix(app/messaging_preview): constraints dialog --- .../ui/manager/sections/social/MessagingPreview.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt index 60c546a91..8df28df76 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt @@ -294,11 +294,13 @@ class MessagingPreview( val hasSelection = selectedMessages.isNotEmpty() ActionButton(text = if (hasSelection) "Save selection" else "Save all", icon = Icons.Rounded.BookmarkAdded) { launchMessagingTask(MessagingTaskType.SAVE) - selectConstraintsDialog = true + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true } ActionButton(text = if (hasSelection) "Unsave selection" else "Unsave all", icon = Icons.Rounded.BookmarkBorder) { launchMessagingTask(MessagingTaskType.UNSAVE) - selectConstraintsDialog = true + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true } ActionButton(text = if (hasSelection) "Mark selected Snap as seen" else "Mark all Snaps as seen", icon = Icons.Rounded.RemoveRedEye) { launchMessagingTask(MessagingTaskType.READ, listOf( @@ -314,7 +316,8 @@ class MessagingPreview( messageSize = messages.size } } - selectConstraintsDialog = true + if (hasSelection) runCurrentTask() + else selectConstraintsDialog = true } } } From d147dc5ce0a92cc803ec7cc6ffbeb5091bc10144 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 12 Nov 2023 23:28:30 +0100 Subject: [PATCH 221/274] feat: anonymize logs --- .../main/kotlin/me/rhunk/snapenhance/LogManager.kt | 14 ++++++++++++-- common/src/main/assets/lang/en_US.json | 4 ++++ .../snapenhance/common/config/impl/Scripting.kt | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index b1d0a84f9..2f2aa8932 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance -import android.content.SharedPreferences import android.util.Log import com.google.gson.GsonBuilder import me.rhunk.snapenhance.common.logger.AbstractLogger @@ -108,6 +107,8 @@ class LogManager( private val LOG_LIFETIME = 24.hours } + private val anonymizeLogs by lazy { !remoteSideContext.config.root.scripting.disableLogAnonymization.get() } + var lineAddListener = { _: LogLine -> } private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") @@ -201,7 +202,16 @@ class LogManager( fun internalLog(tag: String, logLevel: LogLevel, message: Any?) { runCatching { - val line = LogLine(logLevel, getCurrentDateTime(), tag, message.toString()) + val line = LogLine( + logLevel = logLevel, + dateTime = getCurrentDateTime(), + tag = tag, + message = message.toString().let { + if (anonymizeLogs) + it.replace(Regex("[0-9a-f]{8}-[0-9a-f]{4}-{3}[0-9a-f]{12}", RegexOption.MULTILINE), "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + else it + } + ) logFile.appendText("|$line\n", Charsets.UTF_8) lineAddListener(line) Log.println(logLevel.priority, tag, message.toString()) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index fec12d8eb..a37ab2a43 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -619,6 +619,10 @@ "hot_reload": { "name": "Hot Reload", "description": "Automatically reloads scripts when they change" + }, + "disable_log_anonymization": { + "name": "Disable Log Anonymization", + "description": "Disables the anonymization of logs" } } } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt index 0f2d2afa4..a52668373 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt @@ -7,4 +7,5 @@ class Scripting : ConfigContainer() { val developerMode = boolean("developer_mode", false) { requireRestart() } val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER); requireRestart() } val hotReload = boolean("hot_reload", false) + val disableLogAnonymization = boolean("disable_log_anonymization", false) { requireRestart() } } \ No newline at end of file From f16eb3a009a30c6347927a6bfdfe04baa6edfae6 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 18 Nov 2023 11:06:22 +0100 Subject: [PATCH 222/274] feat(core/ui): hide settings gear option --- common/src/main/assets/lang/en_US.json | 6 +++--- .../snapenhance/common/config/impl/UserInterfaceTweaks.kt | 1 + .../snapenhance/core/ui/menu/impl/SettingsGearInjector.kt | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index a37ab2a43..568957ef6 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -290,9 +290,9 @@ "name": "Disable Spotlight", "description": "Disables the Spotlight page" }, - "startup_tab": { - "name": "Startup Tab", - "description": "Change the tab that opens on startup" + "hide_settings_gear": { + "name": "Hide Settings Gear", + "description": "Hides the SnapEnhance Settings Gear in friend feed" }, "story_viewer_override": { "name": "Story Viewer Override", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index ca98d6d4c..021bffdcc 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -44,5 +44,6 @@ class UserInterfaceTweaks : ConfigContainer() { ) { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } + val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") { requireRestart() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt index 2152ccc82..041bccbda 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt @@ -14,6 +14,7 @@ import me.rhunk.snapenhance.core.util.ktx.getStyledAttributes @SuppressLint("DiscouragedApi") class SettingsGearInjector : AbstractMenu() { override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + if (context.config.userInterface.hideSettingsGear.get()) return val firstView = (view as ViewGroup).getChildAt(0) val ngsHovaHeaderSearchIconBackgroundMarginLeft = context.resources.getDimens("ngs_hova_header_search_icon_background_margin_left") From ad048082898d4fd49d1cb4d3c087d891bb397538 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 18 Nov 2023 11:43:25 +0100 Subject: [PATCH 223/274] fix(core/ui): hide download icon for opera viewer with toolbar --- .../snapenhance/core/ui/ViewAppearanceHelper.kt | 9 +++++++++ .../core/ui/menu/impl/OperaDownloadIconMenu.kt | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt index 4cdc2bf91..2d8590c99 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -16,6 +16,7 @@ import android.os.SystemClock import android.view.Gravity import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.widget.Switch import android.widget.TextView import me.rhunk.snapenhance.core.util.ktx.getDimens @@ -69,6 +70,14 @@ fun View.triggerCloseTouchEvent() { } } +fun ViewGroup.children(): List { + val children = mutableListOf() + for (i in 0 until childCount) { + children.add(getChildAt(i)) + } + return children +} + fun View.iterateParent(predicate: (View) -> Boolean) { var parent = this.parent as? View ?: return while (true) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt index 5dcb0e619..20115c543 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaDownloadIconMenu.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getDrawable @@ -34,6 +35,19 @@ class OperaDownloadIconMenu : AbstractMenu() { setOnClickListener { this@OperaDownloadIconMenu.context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.visibility = View.VISIBLE + (parent.parent as? ViewGroup)?.children()?.forEach { child -> + if (child !is ViewGroup) return@forEach + child.children().forEach { + if (it::class.java.name.endsWith("PreviewToolbar")) v.visibility = View.GONE + } + } + } + + override fun onViewDetachedFromWindow(v: View) {} + }) }, 0) } } \ No newline at end of file From e956400ffebe740dc61ecc5ffa0c47c0c5c90706 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 18 Nov 2023 11:57:29 +0100 Subject: [PATCH 224/274] fix(core/send_override): prevent only when story is selected --- .../snapenhance/core/features/impl/messaging/SendOverride.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt index 376703655..4170c5720 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -54,6 +54,7 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI context.config.messaging.galleryMediaSendOverride.get() }) { event -> isLastSnapSavable = false + if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe val localMessageContent = event.messageContent if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA) return@subscribe From feee29509d8f5a6e62bce9693a0f97d0f9b9bc90 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 18 Nov 2023 19:07:35 +0100 Subject: [PATCH 225/274] feat(core): disable confirmation dialogs --- common/src/main/assets/lang/en_US.json | 12 +++++ .../snapenhance/common/config/impl/Global.kt | 1 + .../impl/ui/DisableConfirmationDialogs.kt | 54 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 1 + 4 files changed, 68 insertions(+) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 568957ef6..8e75cf892 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -408,6 +408,10 @@ "name": "Snapchat Plus", "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, + "disable_confirmation_dialogs": { + "name": "Disable Confirmation Dialogs", + "description": "Automatically confirms selected actions" + }, "auto_updater": { "name": "Auto Updater", "description": "Automatically checks for new updates" @@ -738,6 +742,14 @@ "old_bitmoji_selfie": { "2d": "2D Bitmoji", "3d": "3D Bitmoji" + }, + "disable_confirmation_dialogs": { + "remove_friend": "Remove Friend", + "block_friend": "Block Friend", + "ignore_friend": "Ignore Friend", + "hide_friend": "Hide Friend", + "hide_conversation": "Hide Conversation", + "clear_conversation": "Clear Conversation from Friend Feed" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt index c8f6aab57..79bb8dd98 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt @@ -9,6 +9,7 @@ class Global : ConfigContainer() { } val spoofLocation = container("spoofLocation", SpoofLocation()) val snapchatPlus = boolean("snapchat_plus") { requireRestart() } + val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() } val disableMetrics = boolean("disable_metrics") val blockAds = boolean("block_ads") val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt new file mode 100644 index 000000000..e42e15b02 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/DisableConfirmationDialogs.kt @@ -0,0 +1,54 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.view.View +import android.widget.TextView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import java.util.regex.Pattern + +class DisableConfirmationDialogs : Feature("Disable Confirmation Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + val disableConfirmationDialogs = context.config.global.disableConfirmationDialogs.get().takeIf { it.isNotEmpty() } ?: return + val dialogContent = context.resources.getId("dialog_content") + val alertDialogTitle = context.resources.getId("alert_dialog_title") + + val questions = listOf( + "remove_friend" to "action_menu_remove_friend_question", + "block_friend" to "action_menu_block_friend_question", + "ignore_friend" to "action_menu_ignore_friend_question", + "hide_friend" to "action_menu_hide_friend_question", + "hide_conversation" to "hide_or_block_clear_conversation_dialog_title", + "clear_conversation" to "action_menu_clear_conversation_dialog_title" + ).associate { pair -> + pair.first to runCatching { + Pattern.compile( + context.resources.getString(context.resources.getIdentifier(pair.second, "string")) + .split("%s").joinToString(".*") { + Pattern.quote(it) + }, Pattern.CASE_INSENSITIVE) + }.onFailure { + context.log.error("Failed to compile regex for ${pair.second}", it) + }.getOrNull() + } + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.parent.id != dialogContent || !event.view::class.java.name.endsWith("SnapButtonView")) return@subscribe + + val dialogTitle = event.parent.findViewById(alertDialogTitle)?.text?.toString() ?: return@subscribe + + questions.forEach { (key, value) -> + if (!disableConfirmationDialogs.contains(key)) return@forEach + + if (value?.matcher(dialogTitle)?.matches() == true) { + event.parent.visibility = View.GONE + event.parent.postDelayed({ + event.view.callOnClick() + }, 400) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 09accbc61..faba9712f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -107,6 +107,7 @@ class FeatureManager( InstantDelete::class, BypassScreenshotDetection::class, HalfSwipeNotifier::class, + DisableConfirmationDialogs::class, ) initializeFeatures() From 37519ca0d5b269b461624a2c0aa3679b060938ad Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 18 Nov 2023 19:27:29 +0100 Subject: [PATCH 226/274] fix(app/feature_section): navigation popup --- .../manager/sections/features/FeaturesSection.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt index 2de304916..13cd1dea0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/features/FeaturesSection.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable @@ -83,6 +84,13 @@ class FeaturesSection : Section() { properties } + private fun navigateToMainRoot() { + navController.navigate(MAIN_ROUTE, NavOptions.Builder() + .setPopUpTo(navController.graph.findStartDestination().id, false) + .setLaunchSingleTop(true) + .build() + ) + } override fun canGoBack() = sectionTopBarName() != featuresRouteName @@ -385,7 +393,7 @@ class FeaturesSection : Section() { onValueChange = { keyword -> searchValue = keyword if (keyword.isEmpty()) { - navController.navigate(MAIN_ROUTE) + navigateToMainRoot() return@TextField } currentSearchJob?.cancel() @@ -435,7 +443,7 @@ class FeaturesSection : Section() { IconButton(onClick = { showSearchBar = showSearchBar.not() if (!showSearchBar && currentRoute == SEARCH_FEATURE_ROUTE) { - navController.navigate(MAIN_ROUTE) + navigateToMainRoot() } }) { Icon( @@ -504,7 +512,7 @@ class FeaturesSection : Section() { } if (showExportDropdownMenu) { - DropdownMenu(expanded = showExportDropdownMenu, onDismissRequest = { showExportDropdownMenu = false }) { + DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) { actions.forEach { (name, action) -> DropdownMenuItem( text = { From 93a27ed388ce041484664204e75d401ebf3eae76 Mon Sep 17 00:00:00 2001 From: auth <64337177+authorisation@users.noreply.github.com> Date: Sun, 19 Nov 2023 23:59:38 +0100 Subject: [PATCH 227/274] chore: readme link fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73bd3303d..70745f035 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ We do not collect any user information. However, please be aware that third-part ## Contributors -Thanks to everyone involved including the [third-party libraries](https://github.com/rhunk/SnapEnhance/tree/refactor_2_0_0?tab=readme-ov-file#privacy) used! +Thanks to everyone involved including the [third-party libraries](https://github.com/rhunk/SnapEnhance?tab=readme-ov-file#privacy) used! - [rathmerdominik](https://github.com/rathmerdominik) - [Flole998](https://github.com/Flole998) - [authorisation](https://github.com/authorisation/) From 44c7579892cbea5b7a70e006ee6c943591124762 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 21 Nov 2023 18:53:36 +0100 Subject: [PATCH 228/274] feat(common): proto utils --- .../common/util/protobuf/ProtoEditor.kt | 24 +++++++++++++++++++ .../common/util/protobuf/ProtoReader.kt | 4 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt index a70a57b34..dc8758b97 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoEditor.kt @@ -26,6 +26,30 @@ class EditorContext( fun remove(id: Int) = wires.remove(id) fun remove(id: Int, index: Int) = wires[id]?.removeAt(index) + + fun edit(id: Int, callback: EditorContext.() -> Unit) { + val wire = wires[id]?.firstOrNull() ?: return + val editor = ProtoEditor(wire.value as ByteArray) + editor.edit { + callback() + } + remove(id) + addBuffer(id, editor.toByteArray()) + } + + fun editEach(id: Int, callback: EditorContext.() -> Unit) { + val wires = wires[id] ?: return + val newWires = mutableListOf() + wires.toList().forEachIndexed { _, wire -> + val editor = ProtoEditor(wire.value as ByteArray) + editor.edit { + callback() + } + newWires.add(Wire(wire.id, WireType.CHUNK, editor.toByteArray())) + } + wires.clear() + wires.addAll(newWires) + } } class ProtoEditor( diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt index bb5b33ae4..68984b266 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoReader.kt @@ -2,7 +2,9 @@ package me.rhunk.snapenhance.common.util.protobuf import java.util.UUID -data class Wire(val id: Int, val type: WireType, val value: Any) +data class Wire(val id: Int, val type: WireType, val value: Any) { + fun toReader() = ProtoReader(value as ByteArray) +} class ProtoReader(private val buffer: ByteArray) { private var offset: Int = 0 From 06d4bb8563482b8cf86694e0d2ff6c911bffc662 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:01:18 +0100 Subject: [PATCH 229/274] fix(core/messaging): feed cached snap messages filter --- .../rhunk/snapenhance/core/features/impl/messaging/Messaging.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index 302103243..d57fd1527 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -66,7 +66,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C val myUserId = context.database.myUserId feedCachedSnapMessages[conversationId] = messages.filter { msg -> - msg.messageMetadata?.seenBy?.none { it.toString() == myUserId } == true + msg.messageMetadata?.openedBy?.none { it.toString() == myUserId } == true }.sortedBy { it.orderKey }.mapNotNull { it.messageDescriptor?.messageId } } From 82a3847573d66dc26912ededc9cfcd1f70971279 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:05:01 +0100 Subject: [PATCH 230/274] fix(app/messaging_task): reduce process delay --- .../main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt index 409780237..4d1756e72 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/MessagingTask.kt @@ -69,7 +69,7 @@ class MessagingTask( } onSuccess(message) processedMessageCount.intValue++ - delay(Random.nextLong(50, 170)) + delay(Random.nextLong(20, 50)) } } From c0740877fa7fe5f2e830f6343a3b9175acbcd5f8 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 21 Nov 2023 19:29:08 +0100 Subject: [PATCH 231/274] feat: localized content types --- .../ui/manager/sections/social/MessagingPreview.kt | 3 ++- common/src/main/assets/lang/en_US.json | 7 ------- .../snapenhance/common/bridge/wrapper/LocaleWrapper.kt | 1 + .../snapenhance/common/config/impl/MessagingTweaks.kt | 2 +- .../snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt | 5 ++--- 5 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt index 8df28df76..2a8175530 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/MessagingPreview.kt @@ -49,6 +49,7 @@ class MessagingPreview( private lateinit var messagingBridge: MessagingBridge private lateinit var previewScrollState: LazyListState private val myUserId by lazy { messagingBridge.myUserId } + private val contentTypeTranslation by lazy { context.translation.getCategory("content_type") } private var conversationId: String? = null private val messages = sortedMapOf() // server message id => message @@ -385,7 +386,7 @@ class MessagingPreview( .padding(5.dp) ) { - Text("[$contentType] ${messageReader.getString(2, 1) ?: ""}") + Text("[${contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } }] ${messageReader.getString(2, 1) ?: ""}") } } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 8e75cf892..a0449ab92 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -672,13 +672,6 @@ "progress": "Progress", "failure": "Failure" }, - "auto_save_messages_in_conversations": { - "NOTE": "Audio Note", - "CHAT": "Chat", - "EXTERNAL_MEDIA": "External Media", - "SNAP": "Snap", - "STICKER": "Sticker" - }, "notifications": { "chat_screenshot": "Screenshot", "chat_screen_record": "Screen Record", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt index 9b498b18d..4eed2a567 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LocaleWrapper.kt @@ -80,6 +80,7 @@ class LocaleWrapper { } operator fun get(key: String) = translationMap[key] ?: key.also { AbstractLogger.directDebug("Missing translation for $key") } + fun getOrNull(key: String) = translationMap[key] fun format(key: String, vararg args: Pair): String { return args.fold(get(key)) { acc, pair -> diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index c82e3319d..06e7ae84b 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -21,7 +21,7 @@ class MessagingTweaks : ConfigContainer() { "NOTE", "EXTERNAL_MEDIA", "STICKER" - ) { requireRestart() } + ) { requireRestart(); customOptionTranslationPath = "content_type" } val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" nativeHooks() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index ebaaea8ce..c65860159 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -161,6 +161,7 @@ class FriendFeedInfoMenu : AbstractMenu() { .associateBy { it.userId!! } val messageBuilder = StringBuilder() + val translation = context.translation.getCategory("content_type") messages.forEach { message -> val sender = participants[message.senderId] @@ -174,9 +175,7 @@ class FriendFeedInfoMenu : AbstractMenu() { val contentType = ContentType.fromMessageContainer(messageProtoReader) ?: ContentType.fromId(message.contentType) var messageString = if (contentType == ContentType.CHAT) { messageProtoReader.getString(2, 1) ?: return@forEach - } else { - contentType.name - } + } else translation.getOrNull(contentType.name) ?: contentType.name if (contentType == ContentType.SNAP) { messageString = "\uD83D\uDFE5" //red square From 7d4963770da449f259111cdd763769a06fe3fa77 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 21 Nov 2023 23:56:02 +0100 Subject: [PATCH 232/274] feat(core/clean_cache): more file paths --- .../kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt index ff7cee6ee..226e38f53 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt @@ -12,8 +12,11 @@ class CleanCache : AbstractAction() { "files/blizzardv2/*", "files/streaming/*", "cache/*", + "files/streaming/*", "databases/media_packages", "databases/simple_db_helper.db", + "databases/simple_db_helper.db-wal", + "databases/simple_db_helper.db-shm", "databases/journal.db", "databases/arroyo.db", "databases/arroyo.db-wal", From e9b9a71a7ed2da68741a3017d2b668110559d23e Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 00:02:18 +0100 Subject: [PATCH 233/274] feat: story features - disable rewatch indicator - disable public stories --- common/src/main/assets/lang/en_US.json | 8 ++ .../snapenhance/common/config/impl/Global.kt | 1 + .../common/config/impl/MessagingTweaks.kt | 1 + .../snapenhance/core/event/EventDispatcher.kt | 2 + .../events/impl/NetworkApiRequestEvent.kt | 86 ++++++++++++++++++- .../snapenhance/core/features/impl/Stories.kt | 53 ++++++++++++ .../impl/messaging/AnonymousStoryViewing.kt | 27 ------ .../core/manager/impl/FeatureManager.kt | 3 +- .../snapenhance/core/util/hook/Hooker.kt | 7 +- 9 files changed, 157 insertions(+), 31 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AnonymousStoryViewing.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index a0449ab92..8f5698940 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -324,6 +324,10 @@ "name": "Anonymous Story Viewing", "description": "Prevents anyone from knowing you've seen their story" }, + "prevent_story_rewatch_indicator": { + "name": "Prevent Story Rewatch Indicator", + "description": "Prevents anyone from knowing you've rewatched their story" + }, "hide_peek_a_peek": { "name": "Hide Peek-a-Peek", "description": "Prevents notification from being sent when you half swipe into a chat" @@ -420,6 +424,10 @@ "name": "Disable Metrics", "description": "Blocks sending specific analytic data to Snapchat" }, + "disable_public_stories": { + "name": "Disable Public Stories", + "description": "Removes every public story from the Discover page\nMay require a clean cache to work properly" + }, "block_ads": { "name": "Block Ads", "description": "Prevents Advertisements from being displayed" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt index 79bb8dd98..2d0ecded0 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt @@ -11,6 +11,7 @@ class Global : ConfigContainer() { val snapchatPlus = boolean("snapchat_plus") { requireRestart() } val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() } val disableMetrics = boolean("disable_metrics") + val disablePublicStories = boolean("disable_public_stories") { requireRestart(); requireCleanCache() } val blockAds = boolean("block_ads") val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( FeatureNotice.BAN_RISK); requireRestart(); nativeHooks() } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 06e7ae84b..eb522a171 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -7,6 +7,7 @@ import me.rhunk.snapenhance.common.data.NotificationType class MessagingTweaks : ConfigContainer() { val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() } val anonymousStoryViewing = boolean("anonymous_story_viewing") + val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() } val hidePeekAPeek = boolean("hide_peek_a_peek") val hideBitmojiPresence = boolean("hide_bitmoji_presence") val hideTypingNotifications = boolean("hide_typing_notifications") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt index a1a33993d..5cfba6db3 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt @@ -136,11 +136,13 @@ class EventDispatcher( NetworkApiRequestEvent( url = request.getObjectField("mUrl") as String, callback = param.arg(4), + uploadDataProvider = param.argNullable(5), request = request, ).apply { adapter = param } ) { + if (canceled) param.setResult(null) request.setObjectField("mUrl", url) postHookEvent() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt index f57578c63..d19e018b4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/NetworkApiRequestEvent.kt @@ -1,9 +1,93 @@ package me.rhunk.snapenhance.core.event.events.impl import me.rhunk.snapenhance.core.event.events.AbstractHookEvent +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import java.nio.ByteBuffer class NetworkApiRequestEvent( val request: Any, + val uploadDataProvider: Any?, val callback: Any, var url: String, -) : AbstractHookEvent() \ No newline at end of file +) : AbstractHookEvent() { + fun addResultHook(methodName: String, stage: HookStage = HookStage.BEFORE, callback: (HookAdapter) -> Unit) { + Hooker.ephemeralHookObjectMethod( + this.callback::class.java, + this.callback, + methodName, + stage + ) { callback.invoke(it) } + } + + fun onSuccess(callback: HookAdapter.(ByteArray?) -> Unit) { + addResultHook("onSucceeded") { param -> + callback.invoke(param, param.argNullable(2)?.let { + ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) } + }) + } + } + + fun hookRequestBuffer(onRequest: (ByteArray) -> ByteArray) { + val streamDataProvider = this.uploadDataProvider?.let { provider -> + provider::class.java.methods.find { it.name == "getUploadStreamDataProvider" }?.invoke(provider) + } ?: return + val streamDataProviderMethods = streamDataProvider::class.java.methods + + val originalBufferSize = streamDataProviderMethods.find { it.name == "getLength" }?.invoke(streamDataProvider) as? Long ?: return + var originalRequestBuffer = ByteArray(originalBufferSize.toInt()) + streamDataProviderMethods.find { it.name == "read" }?.invoke(streamDataProvider, ByteBuffer.wrap(originalRequestBuffer)) + streamDataProviderMethods.find { it.name == "close" }?.invoke(streamDataProvider) + + runCatching { + originalRequestBuffer = onRequest.invoke(originalRequestBuffer) + }.onFailure { + context.log.error("Failed to hook request buffer", it) + } + + var offset = 0L + val unhooks = mutableListOf<() -> Unit>() + + fun hookObjectMethod(methodName: String, callback: (HookAdapter) -> Unit) { + Hooker.hookObjectMethod( + streamDataProvider::class.java, + streamDataProvider, + methodName, + HookStage.BEFORE + ) { + callback.invoke(it) + }.also { unhooks.addAll(it) } + } + + hookObjectMethod("getLength") { it.setResult(originalRequestBuffer.size.toLong()) } + hookObjectMethod("getOffset") { it.setResult(offset) } + hookObjectMethod("close") { param -> + unhooks.forEach { it.invoke() } + param.setResult(null) + } + hookObjectMethod("rewind") { + offset = 0 + it.setResult(true) + } + hookObjectMethod("read") { param -> + val byteBuffer = param.arg(0) + val length = originalRequestBuffer.size.coerceAtMost(byteBuffer.remaining()) + byteBuffer.put(originalRequestBuffer, offset.toInt(), length) + offset += length + param.setResult(byteBuffer.position().toLong()) + } + + Hooker.hookObjectMethod( + this.uploadDataProvider::class.java, + this.uploadDataProvider, + "getUploadStreamDataProvider", + HookStage.BEFORE + ) { + if (it.nullableThisObject() != this.uploadDataProvider) return@hookObjectMethod + it.setResult(streamDataProvider) + }.also { + unhooks.addAll(it) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt new file mode 100644 index 000000000..390de4e0e --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt @@ -0,0 +1,53 @@ +package me.rhunk.snapenhance.core.features.impl + +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import java.nio.ByteBuffer +import kotlin.coroutines.suspendCoroutine + +class Stories : Feature("Stories", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + val disablePublicStories by context.config.global.disablePublicStories + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + fun cancelRequest() { + runBlocking { + suspendCoroutine { + context.httpServer.ensureServerStarted { + event.url = "http://127.0.0.1:${context.httpServer.port}" + it.resumeWith(Result.success(Unit)) + } + } + } + } + + if (event.url.endsWith("readreceipt-indexer/batchuploadreadreceipts")) { + if (context.config.messaging.anonymousStoryViewing.get()) { + cancelRequest() + return@subscribe + } + if (!context.config.messaging.preventStoryRewatchIndicator.get()) return@subscribe + event.hookRequestBuffer { buffer -> + if (ProtoReader(buffer).getVarInt(2, 7, 4) == 1L) { + cancelRequest() + } + buffer + } + } + + if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) { + event.onSuccess { buffer -> + val payload = ProtoEditor(buffer ?: return@onSuccess).apply { + edit(3) { remove(3) } + }.toByteArray() + setArg(2, ByteBuffer.wrap(payload)) + } + return@subscribe + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AnonymousStoryViewing.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AnonymousStoryViewing.kt deleted file mode 100644 index f972f922c..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AnonymousStoryViewing.kt +++ /dev/null @@ -1,27 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.messaging - -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent -import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.features.FeatureLoadParams -import me.rhunk.snapenhance.core.util.media.HttpServer -import kotlin.coroutines.suspendCoroutine - -class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val anonymousStoryViewProperty by context.config.messaging.anonymousStoryViewing - val httpServer = HttpServer() - - context.event.subscribe(NetworkApiRequestEvent::class, { anonymousStoryViewProperty }) { event -> - if (!event.url.endsWith("readreceipt-indexer/batchuploadreadreceipts")) return@subscribe - runBlocking { - suspendCoroutine { - httpServer.ensureServerStarted { - event.url = "http://127.0.0.1:${httpServer.port}" - it.resumeWith(Result.success(Unit)) - } - } - } - } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index faba9712f..5effa6e56 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -19,6 +19,7 @@ import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.core.features.impl.tweaks.BypassScreenshotDetection +import me.rhunk.snapenhance.core.features.impl.Stories import me.rhunk.snapenhance.core.features.impl.ui.* import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.manager.Manager @@ -68,7 +69,6 @@ class FeatureManager( StealthMode::class, MenuViewInjector::class, PreventReadReceipts::class, - AnonymousStoryViewing::class, MessageLogger::class, SnapchatPlus::class, DisableMetrics::class, @@ -108,6 +108,7 @@ class FeatureManager( BypassScreenshotDetection::class, HalfSwipeNotifier::class, DisableConfirmationDialogs::class, + Stories::class, ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt index 110d05137..3f89fef55 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt @@ -75,8 +75,8 @@ object Hooker { methodName: String, stage: HookStage, crossinline hookConsumer: (HookAdapter) -> Unit - ) { - val unhooks: MutableSet = HashSet() + ): List<() -> Unit> { + val unhooks = mutableSetOf() hook(clazz, methodName, stage) { param-> if (param.nullableThisObject().let { if (it == null) unhooks.forEach { u -> u.unhook() } @@ -84,6 +84,9 @@ object Hooker { }) return@hook hookConsumer(param) }.also { unhooks.addAll(it) } + return unhooks.map { + { it.unhook() } + } } inline fun ephemeralHook( From df808ee8cfe737c3ac18c3e904c0079a69a50b42 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 00:33:23 +0100 Subject: [PATCH 234/274] feat(core/clean_cache): composer cache --- .../kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt index 226e38f53..9482a9451 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/CleanCache.kt @@ -9,6 +9,7 @@ class CleanCache : AbstractAction() { "files/mbgl-offline.db", "files/native_content_manager/*", "files/file_manager/*", + "files/composer_cache/*", "files/blizzardv2/*", "files/streaming/*", "cache/*", From b549f77260dcdc475d6e988737efcbdf58987429 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 15:24:12 +0100 Subject: [PATCH 235/274] feat(manager/lspatch): original keystore --- manager/src/main/assets/lspatch/keystore.jks | Bin 2158 -> 2198 bytes .../manager/patch/util/ApkSignatureHelper.kt | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manager/src/main/assets/lspatch/keystore.jks b/manager/src/main/assets/lspatch/keystore.jks index e5759c38e0df03676ebf5c6a5822869a044919e0..581ea8e0e03a064e890a47e5c59dd8cd1d1be3db 100644 GIT binary patch literal 2198 zcmcK3`8U)H8vyX{EcS*n)?`;HV#aPPClh0`Ma|qMOZI)aBN~kDUKy80w!vgcLRl)x zzP)4{RJe#V7$T9FF48c`dVhn9yPAqZCK3h|6p$y2qwG=2*yIfNST3TeNj2Q{UbCYnjO zVoRXz-#8k0ECZM@ZXBpNZ@V;{qvsrDRX#SdyCk-NIGZ$&mXIoHk&35OWC&~2R!-Ly z8h|Bb&1Yw2Kd(G1Haqc-PJ%9an5GlEN=oxGFUhq$oquOi8uG4atuBq?({Iz4Wz$9V zM=b}g60P&RTu{Yt-PRoWwh0)E@jCopk(a4^jDlgTrI3Nnhf|&myV-`Vt^;zYM5`xh z+c#(Cwe|kR7WME)FQ;#vR~QMqV@VKbRz-zrVgrcPvK3dyPez~J6F8?-tv*tK|JJtx zIKaUW5CAGwM93p#e=JN^7%Bl33!DpSm`Fb!5T}E364Il>X?D)JKM(Q8jrVC9OrMV8A>C$U8MO`Q2E{;IObvTy`;yK8YjnQ)fcfjme%F3s zDvB$9V%6y0f`8#-!nEkb!H_jRQe5a);tgSsV7V+|yU5O0H9z0dBj@~ z!NbC6pH?*mHdUnN4R_5vVQ$47gZJ7Rm{UDXgK}-ymU`C$JI?q5sneF;o%=s z=VdnD(YzeZad9@PWxi-mz47>IOMkf;GFbR@t}~98@>s9*U{>?B{*#P%pLT2+hU9BQ z%Jl}s^E1gfhXW6KX9+!9>jv2*rF?boVdTxww(25{Fr9$OcN5jeMu$Gc<`Rn7RVue( zp5}u=4s>Zlwy=G_c{oyS&k5|0Q#t)SEnqWpE7*`ye7Bxdp~m9%f(dkby^-(IxoaR~ z(nRBInNHL6-yd%3%G?+v``dpe<)WXFqkC9V<{U-))3-T64P7_t(aTe~NZ3_4k8~bv zSvbn-{nhXx+-p0Acff{fpDgXdoblM#lH|6@#*2AxBkEpcYr>er=dI+zoOS#jm13e+ zgO4mGyUABL|0%1H_G^HS=N5LU7!G7s$jw6YHBjonoYLO`oZLAx6PY!ZL*B%EK*z6F6k&2OKhTh zjR$E^x1EtOJS~^bZGP9&R@3m7s}y)C$m;-QpSgUn!}aEM$WhSf)TarXt%|EDE-{i6 zU6-#9Yz&Z72?}~h-7yESabKD3{g3(zJp7sPmJwl8s^)0rxwC0=cPHW>3696)`QFZR zJ#l^xdXY$!Izl)&^4*3TnTu}=pdcyNQ`f3OrP|-n63uqR=L(46BOhKvr`WGeMSj~34 zylF2f-JFj%|L%f>x7APzYwcC1GGDdh4&%uGIoW=}iHK?(YV!~cJ^bxJ=6=kZU9Tk- z3sR^dA~$tmIS_!Vhpa8O-c!mvIYfJv=%SbsU6@DThvt~_YM#r#P5+WV`a#8nc;ul| zQ|GIIZaF3RjJI6`y@FCQVzdwe82Zu}T#3d-w?S>)Qp$^t@!r9fw7myg7N05(446T> zu_ec&o<>^qqcXdWkJT$ZJaw@Cx+PQL!QE4Rqva>7dsU7r>-XkgP!LDfyR%f4kWFQ$ zS<(SJu~8)^McBv?a+OPH_m`=OPTbm|Wi+GS+`M;>n^+S#L$+taEH{yO$J%r81{-t< z>yKAJJs-tg1~b|@wiO#8aMkabVSW?z`^S3s*W7k*?PKz%FfFPUG5jo(x)U1e#g~D& zRNKYLDRkC;LVuTCOgOjnRHgeFKOdiK#U$C|L+qaShhm8;_fZ@=Vs0BZBCp#Ozrys= hp4^PP$~TeE2fRRGJ>rL;l>MpLR#gourOYwR^lwhF+LizS literal 2158 zcmbW0X*iS%8^@o=jAa^Iw#Je)2?;%uUAD42Om?9#jAdHT*rLT4#mEvy2_*@Itl1~g zj9p`2LJ?|&5Rv_K>Uz(I^ZEVyyRQ3p-S_{0-2ecv0RSNMu#280^Exn*c)MVYD1$VS zv_=&H0DA!(;KTXhiT(sU-peD{=aN?#0Dy6}lDh7{r$B)HoRTV}2>>t@M1~1LWN;T$YyT~Ngd!hsf>Y6VlZkL6=ij26yHfm6$2?l8vla-Urh$l|6LPg z2Vi875BPn~J~lE40-wV?9i#ismnyc7`up4DMRl!o0@w1I&#I%o)o%Kx?7R<5d+5=p zIxnhyr;@Hbi(jnXbRKUwWPF4o#kF=d1767ApF?t5xOj1-UqE=W93^RM*G0d6h}*T% zq?UXAzLw!`inDbWzU59-oJ%v$#h4mJF=LHp1w)owfh~Xw=U@F?d4t5tK5A|Jgq@@O z>(0!uNY|FY$vE1*6if+7`QH$K zcl|p=5TGAAMp2jALS_^vH8w6I|k&Z%^tTOIPioDbIKd`W-+tV zSrOoR?PUZiick9k>(jfwM1?C)b0U)e4!m~XZ}AoHcX`k39KF4CINyb+S32T|?g3S& zS*+^?nVnZ$n>(IzOUBP}rrYPJii7Srh4^CzX#`h;RKOj^9IRtmO)fU*aWaXzPVMDDM8rnUV@?rNBQ)~mOOA~zIM<-AmZt}{B%!Iv3)@YiU zE{c5or}sLt{Vdaxn9NV8iepu14D2ji#4>j8{>X55V+ae<{G94!x!zU> z0RVjM&x6@*(Zr zj=(HC{Ly*>{neem()fW&nk(IWFp!WjDG;wTCuYytXyjUxnK-hDi%VQOgQSiyOTWom z4V%r=^UPxX@e4^GKSi%mOV>tg09R0<(`^2+gL>y;?N8^5=O3dPGD^Rq+Cs>C$R{05 z8edd2bM_AOTiPR6^KM0*pR-8N*`jK^_8b1rZqo8#-$YbgpEz;c!`|@=T1`0Iy+<(X znoavV{YQF^}wv8t5%U zA(iyIif2@cBZIyC%EZiEF8Eu3VY)~aCqXq)eTuNvf@z0f-C6)dBhW}9!ZSCsn@&&3 z>6!}o-t5~O?|3G!cu>#i5-vkzbY;-zwU~PPt{?VckS{WGhWo6;)$y}*B@NIsh?EO_X__5*Z zu{I{XKTyzj!|QFG`EORDBzz-k=l$-KgI5k#gs#!Q`x*WnxHZy!WT~X`80RzTehJT0 z`8$`U@+BObkrth`rIA6URp#UR++^+LmvdG&TCHl+oGNAypSwB|P?Zs3) zv?Lx0J}v`~Ain@Dv~I|!o@N?elfSFcq2_HaGy1HKC|Y6AHs6EYQQ@B5a|tjCcvZL= z|3E;DjgVo5t?*n>e@8+tOUL$)TMk;IPL}PnAriYL8?2X}u7{hE>I}VYQQY0$jb29Q@Xa#ZP$Y$&F=BdN8v&wS zozUHQ$!)Z+368L@5^tBn88@S;mo_HuW&HINxApTz?ME^a+}K7{eaVVg-4^&U+^S4Q z&Zu_LfW}*&8K=2a<5HW%P-{l9@4WJAIvwJk_kwXX4rp` z;%h8&GUB-KahoZ-^hh7OT@#6OCM(1siG38be07I%^JvMnDQ;?TZ*pkWmRwEGVzy7d zWu5DfU8N1Brm=VY6c{5nSjh`h1^C;~dR+-DS^18O4fSqN+0yK<8PLB&JzGiYE_Uf6 zuDqO1FQzQi?W$^!@)Q^!PRK`~k|;6B_qqxz2bv8shsHwC8wb#S#Z^4+W1lad)|TE$ zQqr1`KET~Gx*^ozfj`TgA#%Z49& z;eud}z5JYIZJ|20)|DEkzGxUfGVr91ai(peB7H(@2Kon35YeOn diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt index b83407313..5121f3045 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/patch/util/ApkSignatureHelper.kt @@ -23,8 +23,8 @@ object ApkSignatureHelper { fun provideSigningExtension(keyStoreInputStream: InputStream): SigningExtension { val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(keyStoreInputStream, "android".toCharArray()) - val key = keyStore.getEntry("androiddebugkey", KeyStore.PasswordProtection("android".toCharArray())) as KeyStore.PrivateKeyEntry + keyStore.load(keyStoreInputStream, "123456".toCharArray()) + val key = keyStore.getEntry("key0", KeyStore.PasswordProtection("123456".toCharArray())) as KeyStore.PrivateKeyEntry val certificates = key.certificateChain.mapNotNull { it as? X509Certificate }.toTypedArray() return SigningExtension( From 780d5b98588453fe51e8b77203210a79283cff89 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 16:53:21 +0100 Subject: [PATCH 236/274] fix(manager): back handler --- manager/src/main/AndroidManifest.xml | 6 +- .../ui/tab/impl/download/InstallPackageTab.kt | 2 + .../ui/tab/impl/download/LSPatchTab.kt | 197 ++++++++++-------- .../ui/tab/impl/download/SnapchatPatchTab.kt | 6 +- 4 files changed, 119 insertions(+), 92 deletions(-) diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml index e19af72b3..636d87d7f 100644 --- a/manager/src/main/AndroidManifest.xml +++ b/manager/src/main/AndroidManifest.xml @@ -13,7 +13,11 @@ tools:targetApi="34" android:enableOnBackInvokedCallback="true" android:icon="@android:drawable/ic_input_add"> - + diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt index 76334f8e3..3b1ff323f 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/InstallPackageTab.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* @@ -107,6 +108,7 @@ class InstallPackageTab : Tab("install_app") { } else it } ?: false } + BackHandler(installStage != InstallStage.DONE || installStage != InstallStage.ERROR) {} Column( modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt index b5b8afd96..75522e16c 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/LSPatchTab.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.manager.ui.tab.impl.download import android.os.Bundle +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,8 +16,11 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import me.rhunk.snapenhance.manager.data.APKMirror import me.rhunk.snapenhance.manager.data.DownloadItem @@ -25,15 +29,19 @@ import me.rhunk.snapenhance.manager.ui.components.DowngradeNoticeDialog import me.rhunk.snapenhance.manager.ui.tab.Tab import okio.use import java.io.File +import kotlin.properties.Delegates class LSPatchTab : Tab("lspatch") { - private var localItemFile: File? = null - private var downloadItem: DownloadItem? = null - private var snapEnhanceModule: File? = null - private var patchedApk by mutableStateOf(null) private val apkMirror = APKMirror() - private fun patch(log: (Any?) -> Unit, onProgress: (Float) -> Unit) { + private fun patch( + log: (Any?) -> Unit, + onProgress: (Float) -> Unit, + downloadItem: DownloadItem? = null, + snapEnhanceModule: File? = null, + localItemFile: File? = null, + patchedApk: MutableState, + ) { var apkFile: File? = localItemFile downloadItem?.let { @@ -91,118 +99,131 @@ class LSPatchTab : Tab("lspatch") { log("== Patching apk ==") val outputFiles = lsPatch.patchSplits(listOf(apkFile!!)) - patchedApk = outputFiles["base.apk"] ?: run { + patchedApk.value = outputFiles["base.apk"] ?: run { log("== Failed to patch apk ==") return } return } - patchedApk = apkFile + patchedApk.value = apkFile } - @Composable @Suppress("DEPRECATION") - override fun Content() { - this.localItemFile = remember { getArguments()?.getString("localItemFile")?.let { File(it) } } - this.downloadItem = remember { getArguments()?.getParcelable("downloadItem") } - this.snapEnhanceModule = remember { - getArguments()?.getString("modulePath")?.let { - File(it) + override fun build(navGraphBuilder: NavGraphBuilder) { + var currentJob: Job? = null + val coroutineScope = CoroutineScope(Dispatchers.IO) + val patchedApk = mutableStateOf(null) + val status = mutableStateOf("") + var progress by mutableFloatStateOf(-1f) + var isRunning by Delegates.observable(false) { _, _, newValue -> + if (!newValue) { + currentJob?.cancel() + currentJob = null + progress = -1f } } - val coroutineScope = rememberCoroutineScope() - var showDowngradeNoticeDialog by remember { mutableStateOf(false) } - - var status by remember { mutableStateOf("") } - var progress by remember { mutableFloatStateOf(-1f) } - - LaunchedEffect(this.snapEnhanceModule) { - patchedApk = null - coroutineScope.launch(Dispatchers.IO) { - runCatching { - patch(log = { + navGraphBuilder.composable(route) { + var showDowngradeNoticeDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (isRunning) return@LaunchedEffect + status.value = "" + coroutineScope.launch(Dispatchers.IO) { + isRunning = true + runCatching { + patch( + localItemFile = getArguments()?.getString("localItemFile")?.let { File(it) } , + log = { + coroutineScope.launch { + status.value += when (it) { + is Throwable -> it.message + "\n" + it.stackTraceToString() + else -> it.toString() + } + "\n" + } + }, + downloadItem = getArguments()?.getParcelable("downloadItem"), + snapEnhanceModule = getArguments()?.getString("modulePath")?.let { + File(it) + }, + patchedApk = patchedApk, + onProgress = { progress = it } + ) + }.onFailure { coroutineScope.launch { - status += when (it) { - is Throwable -> it.message + "\n" + it.stackTraceToString() - else -> it.toString() - } + "\n" + status.value += it.message + "\n" + it.stackTraceToString() } - }) { - progress = it } - }.onFailure { - coroutineScope.launch { - status += it.message + "\n" + it.stackTraceToString() - } - } + isRunning = false + }.also { currentJob = it } } - } - DisposableEffect(Unit) { - onDispose { - coroutineScope.cancel() + DisposableEffect(Unit) { + onDispose { + if (isRunning) return@onDispose + patchedApk.value = null + } } - } - val scrollState = rememberScrollState() - - fun triggerInstallation(shouldUninstall: Boolean) { - navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { - putString("downloadPath", patchedApk?.absolutePath) - putString("appPackage", sharedConfig.snapchatPackageName) - putBoolean("uninstall", shouldUninstall) - }) - } + val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Card( + fun triggerInstallation(shouldUninstall: Boolean) { + navigation.navigateTo(InstallPackageTab::class, args = Bundle().apply { + putString("downloadPath", patchedApk.value?.absolutePath) + putString("appPackage", sharedConfig.snapchatPackageName) + putBoolean("uninstall", shouldUninstall) + }) + } + BackHandler(isRunning) {} + Column( modifier = Modifier - .weight(1f) - .padding(10.dp), + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Column( + Card( modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) + .weight(1f) + .padding(10.dp), ) { - Text(text = status, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Text(text = status.value, overflow = TextOverflow.Visible, modifier = Modifier.padding(10.dp)) + } } - } - if (progress != -1f) { - LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) - } - - if (patchedApk != null) { - Button(modifier = Modifier.fillMaxWidth(), onClick = { - triggerInstallation(true) - }) { - Text(text = "Uninstall & Install") + if (progress != -1f) { + LinearProgressIndicator(progress = progress, modifier = Modifier.height(10.dp), strokeCap = StrokeCap.Round) } - Button(modifier = Modifier.fillMaxWidth(), onClick = { - showDowngradeNoticeDialog = true - }) { - Text(text = "Update") + if (patchedApk.value != null) { + Button(modifier = Modifier.fillMaxWidth(), onClick = { + triggerInstallation(true) + }) { + Text(text = "Uninstall & Install") + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + showDowngradeNoticeDialog = true + }) { + Text(text = "Update") + } } - } - LaunchedEffect(status) { - scrollState.scrollTo(scrollState.maxValue) + LaunchedEffect(status) { + scrollState.scrollTo(scrollState.maxValue) + } } - } - if (showDowngradeNoticeDialog) { - Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { - DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { - triggerInstallation(false) - }) + if (showDowngradeNoticeDialog) { + Dialog(onDismissRequest = { showDowngradeNoticeDialog = false }) { + DowngradeNoticeDialog(onDismiss = { showDowngradeNoticeDialog = false }, onSuccess = { + triggerInstallation(false) + }) + } } } } diff --git a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt index f452ca55d..fc15e69fa 100644 --- a/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt +++ b/manager/src/main/kotlin/me/rhunk/snapenhance/manager/ui/tab/impl/download/SnapchatPatchTab.kt @@ -279,7 +279,7 @@ class SnapchatPatchTab : Tab("snapchat_download") { navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { putParcelable("downloadItem", selectedSnapchatVersion) putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir) - }, noHistory = true) + }) } ) { Text("Download & Patch") @@ -292,7 +292,7 @@ class SnapchatPatchTab : Tab("snapchat_download") { navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { putString("localItemFile", installedSnapchatPackage?.applicationInfo?.sourceDir ?: return@apply) putString("modulePath", installedSnapEnhanceVersion?.applicationInfo?.sourceDir ?: return@apply) - }, noHistory = true) + }) } ) { Text("Patch from existing installation") @@ -306,7 +306,7 @@ class SnapchatPatchTab : Tab("snapchat_download") { onClick = { navigation.navigateTo(LSPatchTab::class, args = Bundle().apply { putParcelable("downloadItem", selectedSnapchatVersion) - }, noHistory = true) + }) } ) { Text("Install/Restore Original Snapchat") From 15c56b705f99c27905d5719285ae7955858ac2ab Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 20:41:03 +0100 Subject: [PATCH 237/274] feat(app/settings): message logger export & clear --- .../me/rhunk/snapenhance/RemoteSideContext.kt | 4 + .../rhunk/snapenhance/bridge/BridgeService.kt | 4 +- .../ui/manager/sections/home/HomeSection.kt | 2 +- .../manager/sections/home/SettingsSection.kt | 139 ++++++++++++++++-- .../bridge/wrapper/MessageLoggerWrapper.kt | 35 ++++- 5 files changed, 156 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 8461936af..837783373 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -21,8 +21,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.rhunk.snapenhance.bridge.BridgeService import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper import me.rhunk.snapenhance.common.config.ModConfig import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.messaging.ModDatabase @@ -67,6 +69,7 @@ class RemoteSideContext( val scriptManager = RemoteScriptManager(this) val settingsOverlay = SettingsOverlay(this) val e2eeImplementation = E2EEImplementation(this) + val messageLogger by lazy { MessageLoggerWrapper(androidContext.getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)) } //used to load bitmoji selfies and download previews val imageLoader by lazy { @@ -104,6 +107,7 @@ class RemoteSideContext( modDatabase.init() streaksReminder.init() scriptManager.init() + messageLogger.init() }.onFailure { log.error("Failed to load RemoteSideContext", it) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt index ff1e47253..b7a9ab397 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -20,7 +20,6 @@ import me.rhunk.snapenhance.download.DownloadProcessor import kotlin.system.measureTimeMillis class BridgeService : Service() { - private lateinit var messageLoggerWrapper: MessageLoggerWrapper private lateinit var remoteSideContext: RemoteSideContext lateinit var syncCallback: SyncCallback var messagingBridge: MessagingBridge? = null @@ -38,7 +37,6 @@ class BridgeService : Service() { remoteSideContext.apply { bridgeService = this@BridgeService } - messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() } return BridgeBinder() } @@ -180,7 +178,7 @@ class BridgeService : Service() { override fun getScriptingInterface() = remoteSideContext.scriptManager override fun getE2eeInterface() = remoteSideContext.e2eeImplementation - override fun getMessageLogger() = messageLoggerWrapper + override fun getMessageLogger() = remoteSideContext.messageLogger override fun registerMessagingBridge(bridge: MessagingBridge) { messagingBridge = bridge } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 5b934b900..9b38cfbdd 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -271,7 +271,7 @@ class HomeSection : Section() { homeSubSection.LogsSection() } composable(SETTINGS_SECTION_ROUTE) { - SettingsSection().also { it.context = context }.Content() + SettingsSection(activityLauncherHelper).also { it.context = context }.Content() } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt index f748125f1..f849efc0e 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt @@ -6,27 +6,28 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.OpenInNew -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.AlertDialogs +import me.rhunk.snapenhance.ui.util.saveFile -class SettingsSection : Section() { +class SettingsSection( + private val activityLauncherHelper: ActivityLauncherHelper +) : Section() { private val dialogs by lazy { AlertDialogs(context.translation) } @Composable @@ -59,7 +60,7 @@ class SettingsSection : Section() { } } - Row( + ShiftedRow( modifier = Modifier .fillMaxWidth() .height(65.dp) @@ -86,6 +87,22 @@ class SettingsSection : Section() { context.androidContext.startActivity(intent) } + @Composable + private fun ShiftedRow( + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + verticalAlignment: Alignment.Vertical = Alignment.Top, + content: @Composable RowScope.() -> Unit + ) { + Row( + modifier = modifier.padding(start = 26.dp), + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment + ) { content(this) } + } + + + @OptIn(ExperimentalMaterial3Api::class) @Composable override fun Content() { Column( @@ -99,16 +116,106 @@ class SettingsSection : Section() { launchActionIntent(enumAction) } } + RowTitle(title = "Message Logger") + ShiftedRow { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + var storedMessagesCount by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + storedMessagesCount = context.messageLogger.getStoredMessageCount() + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(5.dp) + ) { + Text(text = "$storedMessagesCount messages", modifier = Modifier.weight(1f)) + Button(onClick = { + runCatching { + activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> + context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream -> + context.messageLogger.databaseFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + }.onFailure { + context.log.error("Failed to export database", it) + context.longToast("Failed to export database! ${it.localizedMessage}") + } + }) { + Text(text = "Export") + } + Button(onClick = { + runCatching { + context.messageLogger.clearMessages() + storedMessagesCount = 0 + }.onFailure { + context.log.error("Failed to clear messages", it) + context.longToast("Failed to clear messages! ${it.localizedMessage}") + }.onSuccess { + context.shortToast("Done!") + } + }) { + Text(text = "Clear") + } + } + } + } - RowTitle(title = "Clear Files") - BridgeFileType.entries.forEach { fileType -> - RowAction(title = fileType.displayName, requireConfirmation = true) { + RowTitle(title = "Clear App Files") + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + var selectedFileType by remember { mutableStateOf(BridgeFileType.entries.first()) } + Box( + modifier = Modifier + .weight(1f) + .padding(start = 26.dp) + ) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + TextField( + value = selectedFileType.displayName, + onValueChange = {}, + readOnly = true, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + BridgeFileType.entries.forEach { fileType -> + DropdownMenuItem(onClick = { + expanded = false + selectedFileType = fileType + }, text = { + Text(text = fileType.displayName) + }) + } + } + } + } + Button(onClick = { runCatching { - fileType.resolve(context.androidContext).delete() - context.longToast("Deleted ${fileType.displayName}!") + selectedFileType.resolve(context.androidContext).delete() }.onFailure { - context.longToast("Failed to delete ${fileType.displayName}!") + context.log.error("Failed to clear file", it) + context.longToast("Failed to clear file! ${it.localizedMessage}") + }.onSuccess { + context.shortToast("Done!") } + }) { + Text(text = "Clear") } } } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt index cb425a557..9f757bd25 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt @@ -2,15 +2,18 @@ package me.rhunk.snapenhance.common.bridge.wrapper import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.* import me.rhunk.snapenhance.bridge.MessageLoggerInterface import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper import java.io.File import java.util.UUID class MessageLoggerWrapper( - private val databaseFile: File + val databaseFile: File ): MessageLoggerInterface.Stub() { private var _database: SQLiteDatabase? = null + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) private val database get() = synchronized(this) { _database?.takeIf { it.isOpen } ?: run { @@ -74,19 +77,35 @@ class MessageLoggerWrapper( if (state) { return false } - database.insert("messages", null, ContentValues().apply { - put("conversation_id", conversationId) - put("message_id", messageId) - put("message_data", serializedMessage) - }) + runBlocking { + withContext(coroutineScope.coroutineContext) { + database.insert("messages", null, ContentValues().apply { + put("conversation_id", conversationId) + put("message_id", messageId) + put("message_data", serializedMessage) + }) + } + } return true } fun clearMessages() { - database.execSQL("DELETE FROM messages") + coroutineScope.launch { + database.execSQL("DELETE FROM messages") + } + } + + fun getStoredMessageCount(): Int { + val cursor = database.rawQuery("SELECT COUNT(*) FROM messages", null) + cursor.moveToFirst() + val count = cursor.getInt(0) + cursor.close() + return count } override fun deleteMessage(conversationId: String, messageId: Long) { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + coroutineScope.launch { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } } } \ No newline at end of file From 752f87179fb71a5aa0440bca4159c5246a75a152 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 22 Nov 2023 22:22:15 +0100 Subject: [PATCH 238/274] feat(core): opera media quick info - add creation timestamp to MediaDownloader instead of current timestamp --- common/src/main/assets/lang/en_US.json | 12 +++- .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../impl/downloader/MediaDownloader.kt | 23 +++++-- .../ui/menu/impl/OperaContextActionMenu.kt | 64 ++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 8f5698940..613f198fc 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -282,6 +282,10 @@ "name": "Hide UI Components", "description": "Select which UI components to hide" }, + "opera_media_quick_info": { + "name": "Opera Media Quick Info", + "description": "Shows useful information of media such as creation date in opera viewer context menu" + }, "old_bitmoji_selfie": { "name": "Old Bitmoji Selfie", "description": "Brings back the Bitmoji selfies from older Snapchat versions" @@ -793,7 +797,13 @@ }, "opera_context_menu": { - "download": "Download Media" + "download": "Download Media", + "sent_at": "Sent at {date}", + "created_at": "Created at {date}", + "expires_at": "Expires at {date}", + "media_size": "Media size: {size}", + "media_duration": "Media duration: {duration} ms", + "show_debug_info": "Show Debug Info" }, "modal_option": { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index 021bffdcc..a32e1c011 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -42,6 +42,7 @@ class UserInterfaceTweaks : ConfigContainer() { "hide_chat_call_buttons", "hide_profile_call_buttons" ) { requireRestart() } + val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt index 994fab6a8..8af6fac34 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -72,8 +72,8 @@ class SnapChapterInfo( @OptIn(ExperimentalEncodingApi::class) class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap? = null - private var lastSeenMapParams: ParamMap? = null - + var lastSeenMapParams: ParamMap? = null + private set private val translations by lazy { context.translation.getCategory("download_processor") } @@ -81,6 +81,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private fun provideDownloadManagerClient( mediaIdentifier: String, mediaAuthor: String, + creationTimestamp: Long? = null, downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null ): DownloadManagerClient { @@ -93,7 +94,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp context.shortToast(translations["download_started_toast"]) } - val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) + val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor, creationTimestamp) return DownloadManagerClient( context = context, @@ -133,11 +134,16 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } - private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): String { + private fun createNewFilePath( + hexHash: String, + downloadSource: MediaDownloadSource, + mediaAuthor: String, + creationTimestamp: Long? + ): String { val pathFormat by context.config.downloader.pathFormat val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash } - val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) + val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis()) val finalPath = StringBuilder() @@ -299,6 +305,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", mediaAuthor = authorUsername, + creationTimestamp = conversationMessage.creationTimestamp, downloadSource = MediaDownloadSource.CHAT_MEDIA, friendInfo = author ), mediaInfoMap) @@ -343,6 +350,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = paramMap["MEDIA_ID"].toString(), mediaAuthor = authorName, + creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=") + ?.substringBefore(",")?.toLongOrNull(), downloadSource = MediaDownloadSource.STORY, friendInfo = author ), mediaInfoMap) @@ -360,6 +369,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaAuthor = userDisplayName, downloadSource = MediaDownloadSource.PUBLIC_STORY, + creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), ), mediaInfoMap) return } @@ -369,7 +379,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = paramMap["SNAP_ID"].toString(), downloadSource = MediaDownloadSource.SPOTLIGHT, - mediaAuthor = paramMap["TIME_STAMP"].toString() + mediaAuthor = paramMap["CREATOR_DISPLAY_NAME"].toString(), + creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), ), mediaInfoMap) return } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt index 183243cbf..8159ef3c7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt @@ -7,11 +7,15 @@ import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout import android.widget.ScrollView +import android.widget.TextView import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.wrapper.impl.ScSize +import java.text.DateFormat +import java.util.Date @SuppressLint("DiscouragedApi") class OperaContextActionMenu : AbstractMenu() { @@ -67,11 +71,65 @@ class OperaContextActionMenu : AbstractMenu() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) - val translation = context.translation + val translation = context.translation.getCategory("opera_context_menu") val mediaDownloader = context.feature(MediaDownloader::class) + val paramMap = mediaDownloader.lastSeenMapParams + + if (paramMap != null && context.config.userInterface.operaMediaQuickInfo.get()) { + val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId -> + context.database.getConversationMessageFromId( + messageId.substring(messageId.lastIndexOf(":") + 1) + .toLong() + )?.creationTimestamp + } + ?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() + val dateFormat = DateFormat.getDateTimeInstance() + val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + val expirationTimestamp = playableStorySnapRecord?.substringAfter("expirationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["SNAP_EXPIRATION_TIMESTAMP_MILLIS"]?.toString()?.toLongOrNull() + + val mediaSize = paramMap["snap_size"]?.let { ScSize(it) } + val durationMs = paramMap["media_duration_ms"]?.toString() + + val stringBuilder = StringBuilder().apply { + if (sentTimestamp != null) { + append(translation.format("sent_at", "date" to dateFormat.format(Date(sentTimestamp)))) + append("\n") + } + if (creationTimestamp != null) { + append(translation.format("created_at", "date" to dateFormat.format(Date(creationTimestamp)))) + append("\n") + } + if (expirationTimestamp != null) { + append(translation.format("expires_at", "date" to dateFormat.format(Date(expirationTimestamp)))) + append("\n") + } + if (mediaSize != null) { + append(translation.format("media_size", "size" to "${mediaSize.first}x${mediaSize.second}")) + append("\n") + } + if (durationMs != null) { + append(translation.format("media_duration", "duration" to durationMs)) + append("\n") + } + if (last() == '\n') deleteCharAt(length - 1) + } + + if (stringBuilder.isNotEmpty()) { + linearLayout.addView(TextView(view.context).apply { + text = stringBuilder.toString() + setPadding(40, 10, 0, 0) + }) + } + } linearLayout.addView(Button(view.context).apply { - text = translation["opera_context_menu.download"] + text = translation["download"] setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync() parentView.triggerCloseTouchEvent() @@ -81,7 +139,7 @@ class OperaContextActionMenu : AbstractMenu() { if (context.isDeveloper) { linearLayout.addView(Button(view.context).apply { - text = "Show debug info" + text = translation["show_debug_info"] setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } applyTheme(isAmoled = false) }) From 5dbca7e68f83e0281b1ec6070e4681b93cfd85ce Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 23 Nov 2023 22:38:12 +0100 Subject: [PATCH 239/274] fix(database): integrity check --- .../me/rhunk/snapenhance/core/SnapEnhance.kt | 1 + .../core/database/DatabaseAccess.kt | 37 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt index 6837bb1b3..b845db204 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -135,6 +135,7 @@ class SnapEnhance { mappings.loadFromBridge(bridgeClient) mappings.init(androidContext) + database.init() eventDispatcher.init() //if mappings aren't loaded, we can't initialize features if (!mappings.isMappingsLoaded()) return diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 9304a435d..19cffe0d2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -20,8 +20,14 @@ import me.rhunk.snapenhance.core.manager.Manager class DatabaseAccess( private val context: ModContext ) : Manager { - private val mainDb by lazy { openLocalDatabase("main.db") } - private val arroyoDb by lazy { openLocalDatabase("arroyo.db") } + companion object { + val DATABASES = mapOf( + "main" to "main.db", + "arroyo" to "arroyo.db" + ) + } + private val mainDb by lazy { openLocalDatabase("main") } + private val arroyoDb by lazy { openLocalDatabase("arroyo") } private inline fun SQLiteDatabase.performOperation(crossinline query: SQLiteDatabase.() -> T?): T? { return runCatching { @@ -71,27 +77,46 @@ class DatabaseAccess( } ?: emptyMap()).toMutableMap() } - private fun openLocalDatabase(fileName: String): SQLiteDatabase? { - val dbPath = context.androidContext.getDatabasePath(fileName) + private fun openLocalDatabase(databaseName: String, writeMode: Boolean = false): SQLiteDatabase? { + val dbPath = context.androidContext.getDatabasePath(DATABASES[databaseName]!!) if (!dbPath.exists()) return null return runCatching { SQLiteDatabase.openDatabase( dbPath, OpenParams.Builder() - .setOpenFlags(SQLiteDatabase.OPEN_READONLY) + .setOpenFlags( + if (writeMode) SQLiteDatabase.OPEN_READWRITE or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING + else SQLiteDatabase.OPEN_READONLY + ) .setErrorHandler { context.androidContext.deleteDatabase(dbPath.absolutePath) context.softRestartApp() }.build() ) }.onFailure { - context.log.error("Failed to open database $fileName!", it) + context.log.error("Failed to open database $databaseName!", it) }.getOrNull() } fun hasMain(): Boolean = mainDb?.isOpen == true fun hasArroyo(): Boolean = arroyoDb?.isOpen == true + override fun init() { + // perform integrity check on databases + DATABASES.forEach { (name, fileName) -> + openLocalDatabase(name, writeMode = true)?.apply { + rawQuery("PRAGMA integrity_check", null).use { query -> + if (!query.moveToFirst() || query.getString(0).lowercase() != "ok") { + context.log.error("Failed to perform integrity check on $fileName") + context.androidContext.deleteDatabase(fileName) + return@apply + } + context.log.verbose("database $fileName integrity check passed") + } + }?.close() + } + } + fun finalize() { mainDb?.close() arroyoDb?.close() From fb16a220b72227cc8d4759c2d4aa6623f1586abd Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 23 Nov 2023 22:44:57 +0100 Subject: [PATCH 240/274] fix(core/ff_info_menu): hidden birthday --- common/src/main/assets/lang/en_US.json | 1 + .../rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 613f198fc..ba14834a3 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -829,6 +829,7 @@ "display_name": "Display Name", "added_date": "Added Date", "birthday": "Birthday : {month} {day}", + "hidden_birthday": "Birthday : Hidden", "friendship": "Friendship", "add_source": "Add Source", "snapchat_plus": "Snapchat Plus", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index c65860159..ec7bd4b57 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -88,7 +88,8 @@ class FriendFeedInfoMenu : AbstractMenu() { Calendar.LONG, context.translation.loadedLocale )?.let { - context.translation.format("profile_info.birthday", + if (profile.birthday == 0L) context.translation["profile_info.hidden_birthday"] + else context.translation.format("profile_info.birthday", "month" to it, "day" to profile.birthday.toInt().toString()) }, From ea8723d99067dad86202895dbeb15c8888f7c1b9 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Thu, 23 Nov 2023 22:51:24 +0100 Subject: [PATCH 241/274] fix(core/notifications): include username only for groups --- .../snapenhance/core/features/impl/messaging/Notifications.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index f3b5d1f69..adceed78b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -303,7 +303,8 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } val computeMessages: () -> Unit = { computeNotificationMessages(data.notification, conversationId)} - fun setNotificationText(text: String, includeUsername: Boolean = true) { + fun setNotificationText(text: String) { + val includeUsername = context.database.getDMOtherParticipant(conversationId) == null cachedMessages.computeIfAbsent(conversationId) { sortedMapOf() }[orderKey] = if (includeUsername) "$senderUsername: $text" else text From 1383686a820ddb54bd086d455d1cb0e59649c6b4 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:17:41 +0100 Subject: [PATCH 242/274] fix(common/logger): crash --- app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt | 4 ++-- .../kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt index 2f2aa8932..e9e7a84aa 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -207,7 +207,7 @@ class LogManager( dateTime = getCurrentDateTime(), tag = tag, message = message.toString().let { - if (anonymizeLogs) + if (remoteSideContext.config.isInitialized() && anonymizeLogs) it.replace(Regex("[0-9a-f]{8}-[0-9a-f]{4}-{3}[0-9a-f]{12}", RegexOption.MULTILINE), "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") else it } @@ -217,7 +217,7 @@ class LogManager( Log.println(logLevel.priority, tag, message.toString()) }.onFailure { Log.println(Log.ERROR, tag, "Failed to log message: $message") - Log.println(Log.ERROR, tag, it.toString()) + Log.println(Log.ERROR, tag, it.stackTraceToString()) } } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt index 11aa8abb9..7d70f7c7c 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ModConfig.kt @@ -26,6 +26,8 @@ class ModConfig( lateinit var root: RootConfig private set + fun isInitialized() = ::root.isInitialized + private fun createRootConfig() = RootConfig().apply { lateInit(context) } private fun load() { From 571c2e6c4f6699e48f09771ae9053d3c5926d603 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 24 Nov 2023 19:24:42 +0100 Subject: [PATCH 243/274] fix(core/media_downloader): mediaId uniqueness --- .../core/features/impl/downloader/MediaDownloader.kt | 6 ++++-- .../core/features/impl/downloader/decoder/MessageDecoder.kt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt index 8af6fac34..341b9272a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -86,7 +86,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp friendInfo: FriendInfo? = null ): DownloadManagerClient { val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "") - val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.THREE_D) val downloadLogging by context.config.downloader.logging @@ -300,7 +299,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val author = context.database.getFriendInfo(senderId) ?: return val authorUsername = author.usernameForSorting!! - val mediaId = paramMap["MEDIA_ID"]?.toString()?.split("-")?.getOrNull(1) ?: "" + val mediaId = paramMap["MEDIA_ID"]?.toString()?.let { + if (it.contains("-")) it.substringAfter("-") + else it + }?.substringBefore(".") downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt index 21d375bf3..44e0a3068 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt @@ -16,7 +16,7 @@ data class DecodedAttachment( ) { @OptIn(ExperimentalEncodingApi::class) val mediaUniqueId: String? by lazy { - runCatching { Base64.UrlSafe.decode(mediaUrlKey.toString()) }.getOrNull()?.let { ProtoReader(it).getString(2, 2) } + runCatching { Base64.UrlSafe.decode(mediaUrlKey.toString()) }.getOrNull()?.let { ProtoReader(it).getString(2, 2)?.substringBefore(".") } } } From a5d63f96caba2fc8b8e7084ea2e95b5ba63e46a1 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 24 Nov 2023 22:20:06 +0100 Subject: [PATCH 244/274] fix(core): force upload source quality - fix app experiment crash - fix vertical story viewer --- common/src/main/assets/lang/en_US.json | 17 +++++-------- .../snapenhance/common/config/impl/Global.kt | 2 +- .../common/config/impl/UserInterfaceTweaks.kt | 2 +- .../features/impl/ConfigurationOverride.kt | 25 +++++++++++-------- .../impl/global/MediaQualityLevelOverride.kt | 2 +- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index ba14834a3..61eb4b11a 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -298,9 +298,9 @@ "name": "Hide Settings Gear", "description": "Hides the SnapEnhance Settings Gear in friend feed" }, - "story_viewer_override": { - "name": "Story Viewer Override", - "description": "Turns on certain features which Snapchat hid" + "vertical_story_viewer": { + "name": "Vertical Story Viewer", + "description": "Enables the vertical story viewer for all stories" }, "friend_feed_menu_buttons": { "name": "Friend Feed Menu Buttons", @@ -444,9 +444,9 @@ "name": "Disable Google Play Services Dialogs", "description": "Prevent Google Play Services availability dialogs from being shown" }, - "force_media_source_quality": { - "name": "Force Media Source Quality", - "description": "Forces Snapchat's Media Quality to the specified value" + "force_upload_source_quality": { + "name": "Force Upload Source Quality", + "description": "Forces Snapchat to upload media in the original quality\nPlease note that this may not remove metadata from media" }, "disable_snap_splitting": { "name": "Disable Snap Splitting", @@ -714,11 +714,6 @@ "hide_stickers_button": "Remove Stickers Button", "hide_voice_record_button": "Remove Voice Record Button" }, - "story_viewer_override": { - "OFF": "Off", - "DISCOVER_PLAYBACK_SEEKBAR": "Enable Discover Playback Seekbar", - "VERTICAL_STORY_VIEWER": "Enable Vertical Story Viewer" - }, "hide_story_sections": { "hide_friend_suggestions": "Hide friend suggestions", "hide_friends": "Hide friends section", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt index 2d0ecded0..5a6ec3a71 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Global.kt @@ -16,6 +16,6 @@ class Global : ConfigContainer() { val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( FeatureNotice.BAN_RISK); requireRestart(); nativeHooks() } val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") { requireRestart() } - val forceMediaSourceQuality = boolean("force_media_source_quality") + val forceUploadSourceQuality = boolean("force_upload_source_quality") { requireRestart() } val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.INTERNAL_BEHAVIOR) } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index a32e1c011..052fab0ba 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -46,5 +46,5 @@ class UserInterfaceTweaks : ConfigContainer() { val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } - val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") { requireRestart() } + val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt index 353ebecc0..29cd568b7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt @@ -15,6 +15,12 @@ data class ConfigKeyInfo( val defaultValue: Any? ) +data class ConfigFilter( + val filter: (ConfigKeyInfo) -> Boolean, + val defaultValue: Any?, + val isAppExperiment: Boolean = false +) + class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) { override fun init() { val compositeConfigurationProviderMappings = context.mappings.getMappedMap("CompositeConfigurationProvider") @@ -32,23 +38,20 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea context.log.error("Failed to get config key info", it) }.getOrNull() - val propertyOverrides = mutableMapOf Boolean), Any>>() + val propertyOverrides = mutableMapOf() - fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: Any) { - propertyOverrides[key] = Pair(filter, value) + fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: Any, isAppExperiment: Boolean = false) { + propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment) } overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, true) + overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.forceUploadSourceQuality.get() }, true, isAppExperiment = true) overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, true) overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, true) overrideProperty("ENABLE_LONG_SNAP_SENDING", { context.config.global.disableSnapSplitting.get() }, true) - context.config.userInterface.storyViewerOverride.getNullable()?.let { state -> - overrideProperty("DF_ENABLE_SHOWS_PAGE_CONTROLS", { state == "DISCOVER_PLAYBACK_SEEKBAR" }, true) - overrideProperty("DF_VOPERA_FOR_STORIES", { state == "VERTICAL_STORY_VIEWER" }, true) - } - + overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, true, isAppExperiment = true) overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, false) overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, true) @@ -96,8 +99,8 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea param.setResult(true) return@hook } - propertyOverrides[keyInfo.name]?.let { (filter, value) -> - if (!filter(keyInfo)) return@let + propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) -> + if (!isAppExperiment || !filter(keyInfo)) return@let param.setResult(value) } } @@ -122,7 +125,7 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea } val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook - if (propertyOverride.first(keyInfo)) param.setResult(true) + if (propertyOverride.isAppExperiment && propertyOverride.filter(keyInfo)) param.setResult(true) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaQualityLevelOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaQualityLevelOverride.kt index 5272c5f21..d213fa67d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaQualityLevelOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/MediaQualityLevelOverride.kt @@ -10,7 +10,7 @@ class MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParam val enumQualityLevel = context.mappings.getMappedClass("EnumQualityLevel") val mediaQualityLevelProvider = context.mappings.getMappedMap("MediaQualityLevelProvider") - val forceMediaSourceQuality by context.config.global.forceMediaSourceQuality + val forceMediaSourceQuality by context.config.global.forceUploadSourceQuality context.androidContext.classLoader.loadClass(mediaQualityLevelProvider["class"].toString()).hook( mediaQualityLevelProvider["method"].toString(), From 1917616430ee66b8e2a73ee3a5ae2b8924dd40df Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 24 Nov 2023 22:34:52 +0100 Subject: [PATCH 245/274] feat(core/camera): hevc recording --- common/src/main/assets/lang/en_US.json | 4 ++++ .../kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt | 1 + .../snapenhance/core/features/impl/ConfigurationOverride.kt | 1 + 3 files changed, 6 insertions(+) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 61eb4b11a..18cee4c76 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -497,6 +497,10 @@ "force_camera_source_encoding": { "name": "Force Camera Source Encoding", "description": "Forces the camera source encoding" + }, + "hevc_recording": { + "name": "HEVC Recording", + "description": "Uses HEVC (H.265) codec for video recording" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt index 827da10b0..f267c607a 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt @@ -44,6 +44,7 @@ class Camera : ConfigContainer() { val customFrameRate = unique("custom_frame_rate", "5", "10", "20", "25", "30", "48", "60", "90", "120" ) { addNotices(FeatureNotice.UNSTABLE); addFlags(ConfigFlag.NO_TRANSLATE) } + val hevcRecording = boolean("hevc_recording") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val forceCameraSourceEncoding = boolean("force_camera_source_encoding") val overridePreviewResolution get() = _overridePreviewResolution val overridePictureResolution get() = _overridePictureResolution diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt index 29cd568b7..27b85daa9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt @@ -47,6 +47,7 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, true) overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.forceUploadSourceQuality.get() }, true, isAppExperiment = true) + overrideProperty("CAMERA_ME_ENABLE_HEVC_RECORDING", { context.config.camera.hevcRecording.get() }, true) overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, true) overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, true) overrideProperty("ENABLE_LONG_SNAP_SENDING", { context.config.global.disableSnapSplitting.get() }, true) From 9d93f3086aaf4cc36cf3ae3168410eb0d75cd98e Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 24 Nov 2023 23:34:55 +0100 Subject: [PATCH 246/274] feat(core/block_ads): urls --- .../features/impl/ConfigurationOverride.kt | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt index 27b85daa9..457214a2f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ConfigurationOverride.kt @@ -17,7 +17,7 @@ data class ConfigKeyInfo( data class ConfigFilter( val filter: (ConfigKeyInfo) -> Boolean, - val defaultValue: Any?, + val defaultValue: (ConfigKeyInfo) -> Any?, val isAppExperiment: Boolean = false ) @@ -40,24 +40,33 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea val propertyOverrides = mutableMapOf() - fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: Any, isAppExperiment: Boolean = false) { + fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) { propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment) } - overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, true) - overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.forceUploadSourceQuality.get() }, true, isAppExperiment = true) - - overrideProperty("CAMERA_ME_ENABLE_HEVC_RECORDING", { context.config.camera.hevcRecording.get() }, true) - overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, true) - overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, true) - overrideProperty("ENABLE_LONG_SNAP_SENDING", { context.config.global.disableSnapSplitting.get() }, true) - - overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, true, isAppExperiment = true) - overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, false) - - overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, true) - arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL").forEach { - overrideProperty(it, { context.config.global.blockAds.get() }, "http://127.0.0.1") + overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() }, + { true }) + overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.forceUploadSourceQuality.get() }, + { true }, isAppExperiment = true) + + overrideProperty("CAMERA_ME_ENABLE_HEVC_RECORDING", { context.config.camera.hevcRecording.get() }, + { true }) + overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, + { true }) + overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() }, + { true }) + overrideProperty("ENABLE_LONG_SNAP_SENDING", { context.config.global.disableSnapSplitting.get() }, + { true }) + + overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, + { true }, isAppExperiment = true) + overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, + { false }) + + overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, + { true }) + arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL").forEach { + overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" }) } findClass(compositeConfigurationProviderMappings["class"].toString()).hook( @@ -68,7 +77,7 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea propertyOverrides[propertyKey.name]?.let { (filter, value) -> if (!filter(propertyKey)) return@let - param.setResult(value) + param.setResult(value(propertyKey)) } } @@ -84,8 +93,9 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea } propertyOverrides[key]?.let { (filter, value) -> - if (!filter(getConfigKeyInfo(enumData) ?: return@let)) return@let - setValue(value) + val keyInfo = getConfigKeyInfo(enumData) ?: return@let + if (!filter(keyInfo)) return@let + setValue(value(keyInfo)) } } @@ -95,14 +105,13 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea findClass(appExperimentProviderMappings["GetBooleanAppExperimentClass"].toString()).hook("invoke", HookStage.BEFORE) { param -> val keyInfo = getConfigKeyInfo(param.arg(1)) ?: return@hook - if (keyInfo.defaultValue !is Boolean) return@hook if (customBooleanPropertyRules.any { it(keyInfo) }) { param.setResult(true) return@hook } propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) -> if (!isAppExperiment || !filter(keyInfo)) return@let - param.setResult(value) + param.setResult(value(keyInfo)) } } @@ -119,7 +128,6 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea it.name == appExperimentProviderMappings["hasExperimentMethod"].toString() }.hook(HookStage.BEFORE) { param -> val keyInfo = getConfigKeyInfo(param.arg(0)) ?: return@hook - if (keyInfo.defaultValue !is Boolean) return@hook if (customBooleanPropertyRules.any { it(keyInfo) }) { param.setResult(true) return@hook @@ -132,7 +140,7 @@ class ConfigurationOverride : Feature("Configuration Override", loadParams = Fea if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) { customBooleanPropertyRules.add { key -> - key.category == "PLUS" && key.name?.endsWith("_GATE") == true + key.category == "PLUS" && key.defaultValue is Boolean && key.name?.endsWith("_GATE") == true } } }.onFailure { From 656494ea39ced5bc85022d60b170f0fe88751301 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 12:01:55 +0100 Subject: [PATCH 247/274] feat(app/tasks): cancel task button --- .../snapenhance/ui/manager/sections/TasksSection.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt index 398008c82..0070875f7 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt @@ -148,7 +148,15 @@ class TasksSection : Section() { Column { if (pendingTask != null && !taskStatus.isFinalStage()) { - CircularProgressIndicator(modifier = Modifier.size(30.dp)) + FilledIconButton(onClick = { + runCatching { + pendingTask.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $pendingTask", throwable) + } + }) { + Icon(Icons.Filled.Close, contentDescription = "Cancel") + } } else { when (taskStatus) { TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) From 8fd72d60dfb4b188e0ac12bcf2d0cb0cba11ecda Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 15:54:05 +0100 Subject: [PATCH 248/274] fix(core): disable metrics --- .../core/features/impl/global/DisableMetrics.kt | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt index 043007013..a8ec51418 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/DisableMetrics.kt @@ -3,26 +3,14 @@ package me.rhunk.snapenhance.core.features.impl.global import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams -import me.rhunk.snapenhance.core.util.hook.HookStage -import me.rhunk.snapenhance.core.util.hook.Hooker class DisableMetrics : Feature("DisableMetrics", loadParams = FeatureLoadParams.INIT_SYNC) { override fun init() { val disableMetrics by context.config.global.disableMetrics - Hooker.hook(context.classCache.unifiedGrpcService, "unaryCall", HookStage.BEFORE, - { disableMetrics }) { param -> - val url: String = param.arg(0) - if (url.endsWith("snapchat.valis.Valis/SendClientUpdate") || - url.endsWith("targetingQuery") - ) { - param.setResult(null) - } - } - context.event.subscribe(NetworkApiRequestEvent::class, { disableMetrics }) { param -> val url = param.url - if (url.contains("app-analytics") || url.endsWith("v1/metrics")) { + if (url.contains("app-analytics") || url.endsWith("metrics")) { param.canceled = true } } From 04fcc33264a9652b2580077b079eef68fc80005e Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 16:29:09 +0100 Subject: [PATCH 249/274] perf(core/message_exporter): async download - add retries --- core/src/main/assets/web/export_template.html | 17 +- .../core/action/impl/ExportChatMessages.kt | 94 ++--- .../core/messaging/ConversationExporter.kt | 313 ++++++++++++++++ .../core/messaging/ExportFormat.kt | 9 + .../core/messaging/MessageExporter.kt | 337 ------------------ .../core/wrapper/impl/ConversationManager.kt | 23 ++ .../snapenhance/core/wrapper/impl/Message.kt | 6 + 7 files changed, 415 insertions(+), 384 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt diff --git a/core/src/main/assets/web/export_template.html b/core/src/main/assets/web/export_template.html index 79522c876..adf196818 100644 --- a/core/src/main/assets/web/export_template.html +++ b/core/src/main/assets/web/export_template.html @@ -232,15 +232,18 @@ } function decodeMedia(element) { - const decodedData = new Uint8Array( - inflate( - base64decode( - element.innerHTML.substring(5, element.innerHTML.length - 4) + try { + const decodedData = new Uint8Array( + inflate( + base64decode( + element.innerHTML.substring(5, element.innerHTML.length - 4) + ) ) ) - ) - - return URL.createObjectURL(new Blob([decodedData])) + return URL.createObjectURL(new Blob([decodedData])) + } catch (e) { + return null + } } function makeMain() { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt index ee93a5af0..78b5251b1 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt @@ -5,18 +5,14 @@ import android.content.DialogInterface import android.os.Environment import android.text.InputType import android.widget.EditText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.* import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry import me.rhunk.snapenhance.core.action.AbstractAction import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.messaging.ConversationExporter import me.rhunk.snapenhance.core.messaging.ExportFormat -import me.rhunk.snapenhance.core.messaging.MessageExporter import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.wrapper.impl.Message import java.io.File @@ -83,6 +79,7 @@ class ExportChatMessages : AbstractAction() { context.runOnUiThread { val mediasToDownload = mutableListOf() val contentTypes = arrayOf( + ContentType.CHAT, ContentType.SNAP, ContentType.EXTERNAL_MEDIA, ContentType.NOTE, @@ -142,25 +139,54 @@ class ExportChatMessages : AbstractAction() { } } - private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int): List = suspendCancellableCoroutine { continuation -> - context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(conversationId, - lastMessageId, - amount, onSuccess = { messages -> - continuation.resumeWith(Result.success(messages)) - }, onError = { - continuation.resumeWith(Result.success(emptyList())) - }) ?: continuation.resumeWith(Result.success(emptyList())) + private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long, amount: Int): List = runBlocking { + for (i in 0..5) { + val messages: List? = suspendCancellableCoroutine { continuation -> + context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(conversationId, + lastMessageId, + amount, onSuccess = { messages -> + continuation.resumeWith(Result.success(messages)) + }, onError = { + continuation.resumeWith(Result.success(null)) + }) ?: continuation.resumeWith(Result.success(null)) + } + if (messages != null) return@runBlocking messages + logDialog("Retrying in 1 second...") + delay(1000) + } + logDialog("Failed to fetch messages") + emptyList() } private suspend fun exportFullConversation(friendFeedEntry: FriendFeedEntry) { //first fetch the first message val conversationId = friendFeedEntry.key!! val conversationName = friendFeedEntry.feedDisplayName ?: friendFeedEntry.friendDisplayName!!.split("|").lastOrNull() ?: "unknown" + val conversationParticipants = context.database.getConversationParticipants(friendFeedEntry.key!!) + ?.mapNotNull { + context.database.getFriendInfo(it) + }?.associateBy { it.userId!! } ?: emptyMap() + + val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance") + val outputFile = publicFolder.resolve("conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}") logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName)) - val foundMessages = fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).toMutableList() - var lastMessageId = foundMessages.firstOrNull()?.messageDescriptor?.messageId ?: run { + val conversationExporter = ConversationExporter( + context = context, + friendFeedEntry = friendFeedEntry, + conversationParticipants = conversationParticipants, + exportFormat = exportType!!, + messageTypeFilter = mediaToDownload, + cacheFolder = publicFolder.resolve("cache"), + outputFile = outputFile, + ).apply { init(); printLog = { + logDialog(it.toString()) + } } + + var foundMessageCount = 0 + + var lastMessageId = fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.messageDescriptor?.messageId ?: run { logDialog(context.translation["chat_export.no_messages_found"]) return } @@ -168,40 +194,28 @@ class ExportChatMessages : AbstractAction() { while (true) { val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500) if (fetchedMessages.isEmpty()) break + foundMessageCount += fetchedMessages.size - foundMessages.addAll(fetchedMessages) - if (amountOfMessages != null && foundMessages.size >= amountOfMessages!!) { - foundMessages.subList(amountOfMessages!!, foundMessages.size).clear() + if (amountOfMessages != null && foundMessageCount >= amountOfMessages!!) { + fetchedMessages.subList(0, amountOfMessages!! - foundMessageCount).reversed().forEach { message -> + conversationExporter.readMessage(message) + } break } + fetchedMessages.reversed().forEach { message -> + conversationExporter.readMessage(message) + } + fetchedMessages.firstOrNull()?.let { lastMessageId = it.messageDescriptor!!.messageId!! } - setStatus("Exporting (${foundMessages.size} / ${foundMessages.firstOrNull()?.orderKey})") + setStatus("Exporting (found ${foundMessageCount})") } - val outputFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "SnapEnhance/conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}" - ).also { it.parentFile?.mkdirs() } - + if (exportType == ExportFormat.HTML) conversationExporter.awaitDownload() + conversationExporter.close() logDialog(context.translation["chat_export.writing_output"]) - - runCatching { - MessageExporter( - context = context, - friendFeedEntry = friendFeedEntry, - outputFile = outputFile, - mediaToDownload = mediaToDownload, - printLog = ::logDialog - ).apply { readMessages(foundMessages) }.exportTo(exportType!!) - }.onFailure { - logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) - context.log.error("Failed to export conversation $conversationName", it) - return - } - dialogLogs.clear() logDialog("\n" + context.translation.format("chat_export.exported_to", "path" to outputFile.absolutePath.toString() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt new file mode 100644 index 000000000..7bf718632 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt @@ -0,0 +1,313 @@ +package me.rhunk.snapenhance.core.messaging + +import android.util.Base64InputStream +import android.util.Base64OutputStream +import com.google.gson.stream.JsonWriter +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.Executors +import java.util.zip.Deflater +import java.util.zip.DeflaterInputStream +import java.util.zip.DeflaterOutputStream +import java.util.zip.ZipFile +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +class ConversationExporter( + private val context: ModContext, + private val friendFeedEntry: FriendFeedEntry, + private val conversationParticipants: Map, + private val exportFormat: ExportFormat, + private val messageTypeFilter: List? = null, + private val cacheFolder: File, + private val outputFile: File +) { + lateinit var printLog: (Any?) -> Unit + + private val downloadThreadExecutor = Executors.newFixedThreadPool(4) + private val writeThreadExecutor = Executors.newSingleThreadExecutor() + + private val conversationJsonDataFile by lazy { cacheFolder.resolve("messages.json") } + private val jsonDataWriter by lazy { JsonWriter(conversationJsonDataFile.writer()) } + private val outputFileStream by lazy { outputFile.outputStream() } + private val participants = mutableMapOf() + + fun init() { + when (exportFormat) { + ExportFormat.TEXT -> { + outputFileStream.write("Conversation id: ${friendFeedEntry.key}\n".toByteArray()) + outputFileStream.write("Conversation name: ${friendFeedEntry.feedDisplayName}\n".toByteArray()) + outputFileStream.write("Participants:\n".toByteArray()) + conversationParticipants.forEach { (userId, friendInfo) -> + outputFileStream.write(" $userId: ${friendInfo.displayName}\n".toByteArray()) + } + outputFileStream.write("\n\n".toByteArray()) + } + else -> { + jsonDataWriter.isHtmlSafe = true + jsonDataWriter.serializeNulls = true + + jsonDataWriter.beginObject() + jsonDataWriter.name("conversationId").value(friendFeedEntry.key) + jsonDataWriter.name("conversationName").value(friendFeedEntry.feedDisplayName) + + var index = 0 + + jsonDataWriter.name("participants").apply { + beginObject() + conversationParticipants.forEach { (userId, friendInfo) -> + jsonDataWriter.name(userId).beginObject() + jsonDataWriter.name("id").value(index) + jsonDataWriter.name("displayName").value(friendInfo.displayName) + jsonDataWriter.name("username").value(friendInfo.usernameForSorting) + jsonDataWriter.name("bitmojiSelfieId").value(friendInfo.bitmojiSelfieId) + jsonDataWriter.endObject() + participants[userId] = index++ + } + endObject() + } + + jsonDataWriter.name("messages").beginArray() + + if (exportFormat != ExportFormat.HTML) return + outputFileStream.write(""" + + + + + + + + + """.trimIndent().toByteArray()) + + outputFileStream.write("\n".toByteArray()) + + outputFileStream.flush() + } + } + } + + + @OptIn(ExperimentalEncodingApi::class) + private fun downloadMedia(message: Message) { + downloadThreadExecutor.execute { + MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment -> + if (attachment.mediaUrlKey?.isEmpty() == true) return@decode + val protoMediaReference = Base64.UrlSafe.decode(attachment.mediaUrlKey ?: return@decode) + + for (i in 0..5) { + printLog("downloading ${attachment.mediaUrlKey}... (attempt ${i + 1}/5)") + runCatching { + RemoteMediaResolver.downloadBoltMedia(protoMediaReference, decryptionCallback = { + (attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it) + }) { downloadedInputStream, _ -> + downloadedInputStream.use { inputStream -> + MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> + val mediaKey = "${type}_${Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" + val bufferedInputStream = BufferedInputStream(splitInputStream) + val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) + val mediaFile = cacheFolder.resolve("$mediaKey.${fileType.fileExtension}") + + mediaFile.outputStream().use { fos -> + bufferedInputStream.copyTo(fos) + } + + writeThreadExecutor.execute { + outputFileStream.write("
\n".toByteArray()) + outputFileStream.flush() + } + } + } + } + } + return@decode + }.onFailure { + printLog("failed to download media ${attachment.mediaUrlKey}. retrying...") + it.printStackTrace() + } + } + } + } + } + + fun readMessage(message: Message) { + if (exportFormat == ExportFormat.TEXT) { + val (displayName, senderUsername) = conversationParticipants[message.senderId.toString()]?.let { + it.displayName to it.mutableUsername + } ?: ("" to message.senderId.toString()) + + val date = DateFormat.getDateTimeInstance().format(Date(message.messageMetadata!!.createdAt ?: -1)) + outputFileStream.write("[$date] - $displayName ($senderUsername): ${message.serialize() ?: message.messageContent?.contentType?.name}\n".toByteArray(Charsets.UTF_8)) + return + } + val contentType = message.messageContent?.contentType ?: return + + if (messageTypeFilter != null) { + if (!messageTypeFilter.contains(contentType)) return + + if (contentType == ContentType.NOTE || contentType == ContentType.SNAP || contentType == ContentType.EXTERNAL_MEDIA) { + downloadMedia(message) + } + } + + + jsonDataWriter.apply { + beginObject() + name("orderKey").value(message.orderKey) + name("senderId").value(participants.getOrDefault(message.senderId.toString(), -1)) + name("type").value(message.messageContent!!.contentType.toString()) + + fun addUUIDList(name: String, list: List) { + name(name).beginArray() + list.map { participants.getOrDefault(it.toString(), -1) }.forEach { value(it) } + endArray() + } + + addUUIDList("savedBy", message.messageMetadata!!.savedBy!!) + addUUIDList("seenBy", message.messageMetadata!!.seenBy!!) + addUUIDList("openedBy", message.messageMetadata!!.openedBy!!) + + name("reactions").beginObject() + message.messageMetadata!!.reactions!!.forEach { reaction -> + name(participants.getOrDefault(reaction.userId.toString(), -1L).toString()).value(reaction.reactionId) + } + endObject() + + name("createdTimestamp").value(message.messageMetadata!!.createdAt) + name("readTimestamp").value(message.messageMetadata!!.readAt) + name("serializedContent").value(message.serialize()) + name("rawContent").value(Base64.UrlSafe.encode(message.messageContent!!.content!!)) + name("attachments").beginArray() + MessageDecoder.decode(message.messageContent!!) + .forEach attachments@{ attachments -> + if (attachments.type == AttachmentType.STICKER) //TODO: implement stickers + return@attachments + beginObject() + name("key").value(attachments.mediaUrlKey?.replace("=", "")) + name("type").value(attachments.type.toString()) + name("encryption").apply { + attachments.attachmentInfo?.encryption?.let { encryption -> + beginObject() + name("key").value(encryption.key) + name("iv").value(encryption.iv) + endObject() + } ?: nullValue() + } + endObject() + } + endArray() + endObject() + flush() + } + } + + fun awaitDownload() { + downloadThreadExecutor.shutdown() + downloadThreadExecutor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS) + writeThreadExecutor.shutdown() + writeThreadExecutor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS) + } + + fun close() { + if (exportFormat != ExportFormat.TEXT) { + jsonDataWriter.endArray() + jsonDataWriter.endObject() + jsonDataWriter.flush() + jsonDataWriter.close() + } + + if (exportFormat == ExportFormat.JSON) { + conversationJsonDataFile.inputStream().use { + it.copyTo(outputFileStream) + } + } + + if (exportFormat == ExportFormat.HTML) { + //write the json file + outputFileStream.write("\n".toByteArray()) + printLog("writing template...") + + runCatching { + ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> + //export rawinflate.js + apkFile.getEntry("assets/web/rawinflate.js")?.let { entry -> + outputFileStream.write("\n".toByteArray()) + } + + //export avenir next font + apkFile.getEntry("assets/web/avenir_next_medium.ttf")?.let { entry -> + val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + outputFileStream.write(""" + + """.trimIndent().toByteArray()) + } + + apkFile.getEntry("assets/web/export_template.html")?.let { entry -> + apkFile.getInputStream(entry).copyTo(outputFileStream) + } + + apkFile.close() + } + }.onFailure { + throw Throwable("Failed to read template from apk", it) + } + + outputFileStream.write("".toByteArray()) + } + + outputFileStream.flush() + outputFileStream.close() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt new file mode 100644 index 000000000..d84197e48 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ExportFormat.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.messaging; + +enum class ExportFormat( + val extension: String, +){ + JSON("json"), + TEXT("txt"), + HTML("html"); +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt deleted file mode 100644 index 7a09a8fc4..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/MessageExporter.kt +++ /dev/null @@ -1,337 +0,0 @@ -package me.rhunk.snapenhance.core.messaging - -import android.os.Environment -import android.util.Base64InputStream -import android.util.Base64OutputStream -import com.google.gson.JsonArray -import com.google.gson.JsonNull -import com.google.gson.JsonObject -import de.robv.android.xposed.XposedHelpers -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.BuildConfig -import me.rhunk.snapenhance.common.data.ContentType -import me.rhunk.snapenhance.common.data.FileType -import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry -import me.rhunk.snapenhance.common.database.impl.FriendInfo -import me.rhunk.snapenhance.common.util.protobuf.ProtoReader -import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver -import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType -import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder -import me.rhunk.snapenhance.core.wrapper.impl.Message -import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID -import java.io.BufferedInputStream -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.io.OutputStream -import java.text.SimpleDateFormat -import java.util.Collections -import java.util.Date -import java.util.Locale -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.zip.Deflater -import java.util.zip.DeflaterInputStream -import java.util.zip.DeflaterOutputStream -import java.util.zip.ZipFile -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - - -enum class ExportFormat( - val extension: String, -){ - JSON("json"), - TEXT("txt"), - HTML("html"); -} - -@OptIn(ExperimentalEncodingApi::class) -class MessageExporter( - private val context: ModContext, - private val outputFile: File, - private val friendFeedEntry: FriendFeedEntry, - private val mediaToDownload: List? = null, - private val printLog: (String) -> Unit = {}, -) { - private lateinit var conversationParticipants: Map - private lateinit var messages: List - - fun readMessages(messages: List) { - conversationParticipants = - context.database.getConversationParticipants(friendFeedEntry.key!!) - ?.mapNotNull { - context.database.getFriendInfo(it) - }?.associateBy { it.userId!! } ?: emptyMap() - - if (conversationParticipants.isEmpty()) - throw Throwable("Failed to get conversation participants for ${friendFeedEntry.key}") - - this.messages = messages.sortedBy { it.orderKey } - } - - private fun serializeMessageContent(message: Message): String? { - return if (message.messageContent!!.contentType == ContentType.CHAT) { - ProtoReader(message.messageContent!!.content!!).getString(2, 1) ?: "Failed to parse message" - } else null - } - - private fun exportText(output: OutputStream) { - val writer = output.bufferedWriter() - writer.write("Conversation key: ${friendFeedEntry.key}\n") - writer.write("Conversation Name: ${friendFeedEntry.feedDisplayName}\n") - writer.write("Participants:\n") - conversationParticipants.forEach { (userId, friendInfo) -> - writer.write(" $userId: ${friendInfo.displayName}\n") - } - - writer.write("\nMessages:\n") - messages.forEach { message -> - val sender = conversationParticipants[message.senderId.toString()] - val senderUsername = sender?.usernameForSorting ?: message.senderId.toString() - val senderDisplayName = sender?.displayName ?: message.senderId.toString() - val messageContent = serializeMessageContent(message) ?: message.messageContent!!.contentType?.name - val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(message.messageMetadata!!.createdAt!!)) - writer.write("[$date] - $senderDisplayName (${senderUsername}): $messageContent\n") - } - writer.flush() - } - - private suspend fun exportHtml(output: OutputStream) { - val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } - val mediaFiles = Collections.synchronizedMap(mutableMapOf>()) - val threadPool = Executors.newFixedThreadPool(15) - - withContext(Dispatchers.IO) { - var processCount = 0 - - fun updateProgress(type: String) { - val total = messages.filter { - mediaToDownload?.contains(it.messageContent!!.contentType) ?: false - }.size - processCount++ - printLog("$type $processCount/$total") - } - - messages.filter { - mediaToDownload?.contains(it.messageContent!!.contentType) ?: false - }.forEach { message -> - threadPool.execute { - MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment -> - val protoMediaReference = Base64.UrlSafe.decode(attachment.mediaUrlKey ?: return@decode) - - runCatching { - RemoteMediaResolver.downloadBoltMedia(protoMediaReference, decryptionCallback = { - (attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it) - }) { downloadedInputStream, _ -> - downloadedInputStream.use { inputStream -> - MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> - val fileName = "${type}_${Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" - val bufferedInputStream = BufferedInputStream(splitInputStream) - val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) - val mediaFile = File(downloadMediaCacheFolder, "$fileName.${fileType.fileExtension}") - - FileOutputStream(mediaFile).use { fos -> - bufferedInputStream.copyTo(fos) - } - - mediaFiles[fileName] = fileType to mediaFile - } - } - } - - updateProgress("downloaded") - }.onFailure { - printLog("failed to download media for ${message.messageDescriptor!!.conversationId}_${message.orderKey}") - context.log.error("failed to download media for ${message.messageDescriptor!!.conversationId}_${message.orderKey}", it) - } - } - } - } - - threadPool.shutdown() - threadPool.awaitTermination(30, TimeUnit.DAYS) - processCount = 0 - - printLog("writing downloaded medias...") - - //write the head of the html file - output.write(""" - - - - - - - - - """.trimIndent().toByteArray()) - - output.write("\n".toByteArray()) - - mediaFiles.forEach { (key, filePair) -> - output.write("
\n".toByteArray()) - output.flush() - updateProgress("wrote") - } - printLog("writing json conversation data...") - - //write the json file - output.write("\n".toByteArray()) - - printLog("writing template...") - - runCatching { - ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> - //export rawinflate.js - apkFile.getEntry("assets/web/rawinflate.js")?.let { entry -> - output.write("\n".toByteArray()) - } - - //export avenir next font - apkFile.getEntry("assets/web/avenir_next_medium.ttf")?.let { entry -> - val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) - output.write(""" - - """.trimIndent().toByteArray()) - } - - apkFile.getEntry("assets/web/export_template.html")?.let { entry -> - apkFile.getInputStream(entry).copyTo(output) - } - - apkFile.close() - } - }.onFailure { - throw Throwable("Failed to read template from apk", it) - } - - output.write("".toByteArray()) - output.close() - } - } - - private fun exportJson(output: OutputStream) { - val rootObject = JsonObject().apply { - addProperty("conversationId", friendFeedEntry.key) - addProperty("conversationName", friendFeedEntry.feedDisplayName) - - var index = 0 - val participants = mutableMapOf() - - add("participants", JsonObject().apply { - conversationParticipants.forEach { (userId, friendInfo) -> - add(userId, JsonObject().apply { - addProperty("id", index) - addProperty("displayName", friendInfo.displayName) - addProperty("username", friendInfo.usernameForSorting) - addProperty("bitmojiSelfieId", friendInfo.bitmojiSelfieId) - }) - participants[userId] = index++ - } - }) - add("messages", JsonArray().apply { - messages.forEach { message -> - add(JsonObject().apply { - addProperty("orderKey", message.orderKey) - addProperty("senderId", participants.getOrDefault(message.senderId.toString(), -1)) - addProperty("type", message.messageContent!!.contentType.toString()) - - fun addUUIDList(name: String, list: List) { - add(name, JsonArray().apply { - list.map { participants.getOrDefault(it.toString(), -1) }.forEach { add(it) } - }) - } - - addUUIDList("savedBy", message.messageMetadata!!.savedBy!!) - addUUIDList("seenBy", message.messageMetadata!!.seenBy!!) - addUUIDList("openedBy", message.messageMetadata!!.openedBy!!) - - add("reactions", JsonObject().apply { - message.messageMetadata!!.reactions!!.forEach { reaction -> - addProperty( - participants.getOrDefault(reaction.userId.toString(), -1L).toString(), - reaction.reactionId - ) - } - }) - - addProperty("createdTimestamp", message.messageMetadata!!.createdAt) - addProperty("readTimestamp", message.messageMetadata!!.readAt) - addProperty("serializedContent", serializeMessageContent(message)) - addProperty("rawContent", Base64.UrlSafe.encode(message.messageContent!!.content!!)) - - add("attachments", JsonArray().apply { - MessageDecoder.decode(message.messageContent!!) - .forEach attachments@{ attachments -> - if (attachments.type == AttachmentType.STICKER) //TODO: implement stickers - return@attachments - add(JsonObject().apply { - addProperty("key", attachments.mediaUrlKey?.replace("=", "")) - addProperty("type", attachments.type.toString()) - add("encryption", attachments.attachmentInfo?.encryption?.let { encryption -> - JsonObject().apply { - addProperty("key", encryption.key) - addProperty("iv", encryption.iv) - } - } ?: JsonNull.INSTANCE) - }) - } - }) - }) - } - }) - } - - output.write(context.gson.toJson(rootObject).toByteArray()) - output.flush() - } - - suspend fun exportTo(exportFormat: ExportFormat) { - withContext(Dispatchers.IO) { - FileOutputStream(outputFile).apply { - when (exportFormat) { - ExportFormat.HTML -> exportHtml(this) - ExportFormat.JSON -> exportJson(this) - ExportFormat.TEXT -> exportText(this) - } - close() - } - } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt index 29a664436..d5f20e22f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core.wrapper.impl import me.rhunk.snapenhance.common.data.MessageUpdate import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.wrapper.AbstractWrapper @@ -18,6 +19,7 @@ class ConversationManager( private val fetchConversationWithMessagesPaginatedMethod by lazy { findMethodByName("fetchConversationWithMessagesPaginated") } private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") } private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") } + private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") } private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } private val fetchMessage by lazy { findMethodByName("fetchMessage") } @@ -105,4 +107,25 @@ class ConversationManager( }.build() ) } + + fun fetchMessagesByServerIds(conversationId: String, serverMessageIds: List, onSuccess: (List) -> Unit, onError: (error: String) -> Unit) { + fetchMessagesByServerIds.invoke( + instanceNonNull(), + serverMessageIds.map { + CallbackBuilder.createEmptyObject(context.classCache.serverMessageIdentifier.constructors.first())?.apply { + setObjectField("mServerConversationId", conversationId.toSnapUUID().instanceNonNull()) + setObjectField("mServerMessageId", it) + } + }, + CallbackBuilder(context.mappings.getMappedClass("callbacks", "FetchMessagesByServerIdsCallback")) + .override("onSuccess") { param -> + onSuccess(param.arg>(0).mapNotNull { + Message(it?.getObjectField("mMessage") ?: return@mapNotNull null) + }) + } + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt index fdf47d38e..b8fd30605 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt @@ -1,6 +1,8 @@ package me.rhunk.snapenhance.core.wrapper.impl +import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import org.mozilla.javascript.annotations.JSGetter import org.mozilla.javascript.annotations.JSSetter @@ -18,4 +20,8 @@ class Message(obj: Any?) : AbstractWrapper(obj) { var messageMetadata by field("mMetadata") { MessageMetadata(it) } @get:JSGetter @set:JSSetter var messageState by enum("mState", MessageState.COMMITTED) + + fun serialize() = if (messageContent!!.contentType == ContentType.CHAT) { + ProtoReader(messageContent!!.content!!).getString(2, 1) ?: "Failed to parse message" + } else null } \ No newline at end of file From 368878abd7c218494a0c314836817657ddab24c8 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 16:38:57 +0100 Subject: [PATCH 250/274] fix(core/message_exporter): missing mkdirs --- .../rhunk/snapenhance/core/action/impl/ExportChatMessages.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt index 78b5251b1..e45536a33 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportChatMessages.kt @@ -167,7 +167,7 @@ class ExportChatMessages : AbstractAction() { context.database.getFriendInfo(it) }?.associateBy { it.userId!! } ?: emptyMap() - val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance") + val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance").also { if (!it.exists()) it.mkdirs() } val outputFile = publicFolder.resolve("conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}") logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName)) @@ -178,7 +178,7 @@ class ExportChatMessages : AbstractAction() { conversationParticipants = conversationParticipants, exportFormat = exportType!!, messageTypeFilter = mediaToDownload, - cacheFolder = publicFolder.resolve("cache"), + cacheFolder = publicFolder.resolve("cache").also { if (!it.exists()) it.mkdirs() }, outputFile = outputFile, ).apply { init(); printLog = { logDialog(it.toString()) From f66859b1fd4cf59c471837fc25afd9c47f238f59 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 22:15:35 +0100 Subject: [PATCH 251/274] feat(action): bulk remove friends --- common/src/main/assets/lang/en_US.json | 35 ++++-- .../snapenhance/common/action/EnumAction.kt | 3 +- .../common/database/impl/FriendInfo.kt | 4 +- .../core/action/impl/BulkRemoveFriends.kt | 113 ++++++++++++++++++ .../core/database/DatabaseAccess.kt | 19 +++ .../impl/experiments/AddFriendSourceSpoof.kt | 14 ++- .../core/manager/impl/ActionManager.kt | 2 + .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 2 +- .../impl/FriendRelationshipChangerMapper.kt | 11 +- 9 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 18cee4c76..e8978d507 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -115,7 +115,8 @@ "refresh_mappings": "Refresh Mappings", "open_map": "Choose location on map", "check_for_updates": "Check for updates", - "export_chat_messages": "Export Chat Messages" + "export_chat_messages": "Export Chat Messages", + "bulk_remove_friends": "Bulk Remove Friends" }, "features": { @@ -835,16 +836,28 @@ "snapchat_plus_state": { "subscribed": "Subscribed", "not_subscribed": "Not Subscribed" - }, - "friendship_link_type": { - "mutual": "Mutual", - "outgoing": "Outgoing", - "blocked": "Blocked", - "deleted": "Deleted", - "following": "Following", - "suggested": "Suggested", - "incoming": "Incoming", - "incoming_follower": "Incoming Follower" + } + }, + + "friendship_link_type": { + "mutual": "Mutual", + "outgoing": "Outgoing", + "blocked": "Blocked", + "deleted": "Deleted", + "following": "Following", + "suggested": "Suggested", + "incoming": "Incoming", + "incoming_follower": "Incoming Follower" + }, + + "bulk_remove_friends": { + "title": "Bulk Remove Friend", + "progress_status": "Removing friends {index} of {total}", + "selection_dialog_title": "Select friends to remove", + "selection_dialog_remove_button": "Remove Selection", + "confirmation_dialog": { + "title": "Are you sure?", + "message": "This will remove all selected friends. This action cannot be undone." } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt index 2316156f5..0a79bb03b 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -8,7 +8,8 @@ enum class EnumAction( val isCritical: Boolean = false, ) { CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true), - EXPORT_CHAT_MESSAGES("export_chat_messages"); + EXPORT_CHAT_MESSAGES("export_chat_messages"), + BULK_REMOVE_FRIENDS("bulk_remove_friends"); companion object { const val ACTION_PARAMETER = "se_action" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt index 9c9599e10..34f04a0e5 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendInfo.kt @@ -22,8 +22,8 @@ data class FriendInfo( var friendmojiCategories: String? = null, var snapScore: Int = 0, var birthday: Long = 0, - var addedTimestamp: Long = 0, - var reverseAddedTimestamp: Long = 0, + var addedTimestamp: Long = -1, + var reverseAddedTimestamp: Long = -1, var serverDisplayName: String? = null, var streakLength: Int = 0, var streakExpirationTimestamp: Long = 0, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt new file mode 100644 index 000000000..a5efbda90 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt @@ -0,0 +1,113 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.widget.ProgressBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.core.action.AbstractAction +import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper + +class BulkRemoveFriends : AbstractAction() { + private val translation by lazy { context.translation.getCategory("bulk_remove_friends") } + + private fun removeFriends(friendIds: List) { + var index = 0 + val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle("...") + .setView(ProgressBar(context.mainActivity)) + .setCancelable(false) + .show() + + context.coroutineScope.launch { + friendIds.forEach { + removeFriend(it) + index++ + withContext(Dispatchers.Main) { + dialog.setTitle( + translation.format("progress_status", "index" to index.toString(), "total" to friendIds.size.toString()) + ) + } + delay(500) + } + withContext(Dispatchers.Main) { + dialog.dismiss() + } + } + } + + private fun confirmationDialog(onConfirm: () -> Unit) { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["confirmation_dialog.title"]) + .setMessage(translation["confirmation_dialog.message"]) + .setPositiveButton(context.translation["button.positive"]) { _, _ -> + onConfirm() + } + .setNegativeButton(context.translation["button.negative"]) { _, _ -> } + .show() + } + + override fun run() { + val userIdBlacklist = arrayOf( + context.database.myUserId, + "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai + "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat + ) + context.coroutineScope.launch(Dispatchers.Main) { + val friends = context.database.getAllFriends().filter { + it.userId !in userIdBlacklist && + it.addedTimestamp != -1L && + it.friendLinkType == FriendLinkType.MUTUAL.value || + it.friendLinkType == FriendLinkType.OUTGOING.value + }.sortedByDescending { + it.friendLinkType == FriendLinkType.OUTGOING.value + } + + val selectedFriends = mutableListOf() + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["selection_dialog_title"]) + .setMultiChoiceItems(friends.map { friend -> + (friend.displayName?.let { + "$it (${friend.mutableUsername})" + } ?: friend.mutableUsername) + + ": ${context.translation["friendship_link_type.${FriendLinkType.fromValue(friend.friendLinkType).shortName}"]}" + }.toTypedArray(), null) { _, which, isChecked -> + if (isChecked) { + selectedFriends.add(friends[which].userId!!) + } else { + selectedFriends.remove(friends[which].userId) + } + } + .setPositiveButton(translation["selection_dialog_remove_button"]) { _, _ -> + confirmationDialog { + removeFriends(selectedFriends) + } + } + .setNegativeButton(context.translation["button.cancel"]) { _, _ -> } + .show() + } + } + + private fun removeFriend(userId: String) { + val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") + val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!! + + val removeFriendMethod = friendRelationshipChangerInstance::class.java.methods.first { + it.name == friendRelationshipChangerMapping["removeFriendMethod"].toString() + } + + val completable = removeFriendMethod.invoke(friendRelationshipChangerInstance, + userId, // userId + removeFriendMethod.parameterTypes[1].enumConstants.first { it.toString() == "DELETED_BY_MY_FRIENDS" }, // source + null, // unknown + null, // unknown + null // InteractionPlacementInfo + )!! + completable::class.java.methods.first { + it.name == "subscribe" && it.parameterTypes.isEmpty() + }.invoke(completable) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 19cffe0d2..6abfc4b13 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -187,6 +187,25 @@ class DatabaseAccess( } } + fun getAllFriends(): List { + return mainDb?.performOperation { + safeRawQuery( + "SELECT * FROM FriendWithUsername", + null + )?.use { query -> + val list = mutableListOf() + while (query.moveToNext()) { + val friendInfo = FriendInfo() + try { + friendInfo.write(query) + } catch (_: Throwable) {} + list.add(friendInfo) + } + list + } + } ?: emptyList() + } + fun getFeedEntries(limit: Int): List { return mainDb?.performOperation { safeRawQuery( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt index 593d74a38..76fa8eebb 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AddFriendSourceSpoof.kt @@ -4,17 +4,23 @@ import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor -class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { +class AddFriendSourceSpoof : Feature("AddFriendSourceSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + var friendRelationshipChangerInstance: Any? = null + private set + + override fun onActivityCreate() { val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") + findClass(friendRelationshipChangerMapping["class"].toString()).hookConstructor(HookStage.AFTER) { param -> + friendRelationshipChangerInstance = param.thisObject() + } + findClass(friendRelationshipChangerMapping["class"].toString()) .hook(friendRelationshipChangerMapping["addFriendMethod"].toString(), HookStage.BEFORE) { param -> val spoofedSource = context.config.experimental.addFriendSourceSpoof.getNullable() ?: return@hook - context.log.verbose("addFriendMethod: ${param.args().toList()}", featureKey) - fun setEnum(index: Int, value: String) { val enumData = param.arg(index) enumData::class.java.enumConstants.first { it.toString() == value }.let { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt index e1152c21a..f3d3fc5e9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core.manager.impl import android.content.Intent import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.action.impl.BulkRemoveFriends import me.rhunk.snapenhance.core.action.impl.CleanCache import me.rhunk.snapenhance.core.action.impl.ExportChatMessages import me.rhunk.snapenhance.core.manager.Manager @@ -15,6 +16,7 @@ class ActionManager( mapOf( EnumAction.CLEAN_CACHE to CleanCache::class, EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class, + EnumAction.BULK_REMOVE_FRIENDS to BulkRemoveFriends::class, ).map { it.key to it.value.java.getConstructor().newInstance().apply { this.context = modContext diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index ec7bd4b57..5b89da5b7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -94,7 +94,7 @@ class FriendFeedInfoMenu : AbstractMenu() { "day" to profile.birthday.toInt().toString()) }, translation["friendship"] to run { - translation.getCategory("friendship_link_type")[FriendLinkType.fromValue(profile.friendLinkType).shortName] + context.translation["friendship_link_type.${FriendLinkType.fromValue(profile.friendLinkType).shortName}"] }, translation["add_source"] to context.database.getAddSource(profile.userId!!)?.takeIf { it.isNotEmpty() }, translation["snapchat_plus"] to run { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt index e2222cd65..d122b9556 100644 --- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/FriendRelationshipChangerMapper.kt @@ -18,9 +18,18 @@ class FriendRelationshipChangerMapper : AbstractClassMapper() { it.parameters[4].type == "Ljava/lang/String;" } + val removeFriendMethod = classDef.methods.first { + it.parameterTypes.size == 5 && + it.parameterTypes[0] == "Ljava/lang/String;" && + getClass(it.parameterTypes[1])?.isEnum() == true && + it.parameterTypes[2] == "Ljava/lang/String;" && + it.parameterTypes[3] == "Ljava/lang/String;" + } + addMapping("FriendRelationshipChanger", "class" to classDef.getClassName(), - "addFriendMethod" to addFriendMethod.name + "addFriendMethod" to addFriendMethod.name, + "removeFriendMethod" to removeFriendMethod.name ) return@mapper } From 5d370776a55bc4f0be02813426fe3002cd93eaab Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 25 Nov 2023 22:21:40 +0100 Subject: [PATCH 252/274] fix(action/bulk_remove_friends): error handling --- .../snapenhance/core/action/impl/BulkRemoveFriends.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt index a5efbda90..426034bb4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt @@ -22,8 +22,13 @@ class BulkRemoveFriends : AbstractAction() { .show() context.coroutineScope.launch { - friendIds.forEach { - removeFriend(it) + friendIds.forEach { userId -> + runCatching { + removeFriend(userId) + }.onFailure { + context.log.error("Failed to remove friend $it", it) + context.shortToast("Failed to remove friend $userId") + } index++ withContext(Dispatchers.Main) { dialog.setTitle( @@ -55,6 +60,7 @@ class BulkRemoveFriends : AbstractAction() { "b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai "84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat ) + context.coroutineScope.launch(Dispatchers.Main) { val friends = context.database.getAllFriends().filter { it.userId !in userIdBlacklist && From 284417b87da057a05f51878ad4c12fbd3966e673 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 26 Nov 2023 12:31:14 +0100 Subject: [PATCH 253/274] fix: dialog overlay not showing --- .../main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 2 +- .../main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt | 5 ++++- .../me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt | 3 +-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt index 837783373..cb4eaee43 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -113,7 +113,7 @@ class RemoteSideContext( } scriptManager.runtime.eachModule { - callFunction("module.onManagerLoad",androidContext) + callFunction("module.onManagerLoad", androidContext) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt index 7da09d393..af6bf3a8c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/SharedContextHolder.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance +import android.app.Activity import android.content.Context import java.lang.ref.WeakReference @@ -8,7 +9,9 @@ object SharedContextHolder { fun remote(context: Context): RemoteSideContext { if (!::_remoteSideContext.isInitialized || _remoteSideContext.get() == null) { - _remoteSideContext = WeakReference(RemoteSideContext(context)) + _remoteSideContext = WeakReference(RemoteSideContext(context.let { + if (it is Activity) it.applicationContext else it + })) _remoteSideContext.get()?.reload() } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt index 86bae5c5a..41260fe1e 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AndroidDialogCustom.kt @@ -82,7 +82,6 @@ fun Dialog( properties: DialogProperties = DialogProperties(), content: @Composable () -> Unit ) { - val context = LocalContext.current val view = LocalView.current val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current @@ -112,7 +111,7 @@ fun Dialog( DisposableEffect(dialog) { // Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays - if (Settings.canDrawOverlays(view.context) && context !is Activity) { + if (Settings.canDrawOverlays(view.context) && view.context !is Activity) { dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY) } dialog.show() From a6d5d289a42a3f9c8b618f85613e549c4d17445b Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 26 Nov 2023 17:47:54 +0100 Subject: [PATCH 254/274] feat(experiments): disable composer modules --- common/src/main/assets/lang/en_US.json | 4 ++++ .../common/config/impl/Experimental.kt | 1 + .../me/rhunk/snapenhance/core/ModContext.kt | 3 ++- .../experiments/DisableComposerModules.kt | 22 ++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 1 + native/jni/src/config.h | 1 + native/jni/src/library.cpp | 23 ++++++++++++++++++- .../snapenhance/nativelib/NativeConfig.kt | 3 ++- .../rhunk/snapenhance/nativelib/NativeLib.kt | 7 ++++++ 9 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DisableComposerModules.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index e8978d507..56e12bd51 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -622,6 +622,10 @@ "hidden_snapchat_plus_features": { "name": "Hidden Snapchat Plus Features", "description": "Enables unreleased/beta Snapchat Plus features\nMight not work on older Snapchat versions" + }, + "disable_composer_modules": { + "name": "Disable Composer Modules", + "description": "Prevents selected composer modules from being loaded\nNames must be separated by a comma" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt index 481f57d7c..d1d74631a 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -30,4 +30,5 @@ class Experimental : ConfigContainer() { "added_by_qr_code", "added_by_community", ) { addNotices(FeatureNotice.BAN_RISK) } + val disableComposerModules = string("disable_composer_modules") { requireRestart(); nativeHooks() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt index 9004507a5..b54ceb753 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -149,7 +149,8 @@ class ModContext( native.loadNativeConfig( NativeConfig( disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), - disableMetrics = config.global.disableMetrics.get() + disableMetrics = config.global.disableMetrics.get(), + hookAssetOpen = config.experimental.disableComposerModules.get().isNotEmpty() ) ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DisableComposerModules.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DisableComposerModules.kt new file mode 100644 index 000000000..195374dbe --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/DisableComposerModules.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams + +class DisableComposerModules : Feature("Disable Composer Modules", FeatureLoadParams.INIT_SYNC) { + override fun init() { + val disabledComposerModules = context.config.experimental.disableComposerModules.get().takeIf { it.isNotEmpty() } + ?.replace(" ", "") + ?.split(",") + ?: return + + context.native.nativeShouldLoadAsset = callback@{ assetName -> + if (!assetName.endsWith(".composermodule")) return@callback true + val moduleName = assetName.replace(".composermodule", "") + disabledComposerModules.contains(moduleName).not().also { + if (it) context.log.debug("Loading $moduleName composer module") + else context.log.warn("Skipping $moduleName composer module") + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 5effa6e56..38ff29689 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -109,6 +109,7 @@ class FeatureManager( HalfSwipeNotifier::class, DisableConfirmationDialogs::class, Stories::class, + DisableComposerModules::class, ) initializeFeatures() diff --git a/native/jni/src/config.h b/native/jni/src/config.h index 1544d3aee..5fa971f98 100644 --- a/native/jni/src/config.h +++ b/native/jni/src/config.h @@ -3,4 +3,5 @@ typedef struct { bool disable_bitmoji; bool disable_metrics; + bool hook_asset_open; } native_config_t; \ No newline at end of file diff --git a/native/jni/src/library.cpp b/native/jni/src/library.cpp index cd01f710c..acb0fc7d7 100644 --- a/native/jni/src/library.cpp +++ b/native/jni/src/library.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "logger.h" #include "config.h" @@ -16,9 +17,14 @@ static native_config_t *native_config; static JavaVM *java_vm; +static jobject native_lib_object; static jmethodID native_lib_on_unary_call_method; +static jmethodID native_lib_on_asset_load; + +// original functions static void *(*unaryCall_original)(void *, const char *, grpc::grpc_byte_buffer **, void *, void *, void *); static auto fstat_original = (int (*)(int, struct stat *)) nullptr; +static AAsset* (*AAssetManager_open_original)(AAssetManager*, const char*, int) = nullptr; static int fstat_hook(int fd, struct stat *buf) { char name[256]; @@ -40,7 +46,6 @@ static int fstat_hook(int fd, struct stat *buf) { return fstat_original(fd, buf); } -static jobject native_lib_object; static void *unaryCall_hook(void *unk1, const char *uri, grpc::grpc_byte_buffer **buffer_ptr, void *unk4, void *unk5, void *unk6) { // request without reference counter can be hooked using xposed ig @@ -91,6 +96,19 @@ static void *unaryCall_hook(void *unk1, const char *uri, grpc::grpc_byte_buffer return unaryCall_original(unk1, uri, buffer_ptr, unk4, unk5, unk6); } +static AAsset* AAssetManager_open_hook(AAssetManager* mgr, const char* filename, int mode) { + if (native_config->hook_asset_open) { + JNIEnv *env = nullptr; + java_vm->GetEnv((void **)&env, JNI_VERSION_1_6); + + if (!env->CallBooleanMethod(native_lib_object, native_lib_on_asset_load, env->NewStringUTF(filename))) { + return nullptr; + } + } + + return AAssetManager_open_original(mgr, filename, mode); +} + void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { LOGD("Initializing native"); // config @@ -99,6 +117,7 @@ void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { // native lib object native_lib_object = env->NewGlobalRef(clazz); native_lib_on_unary_call_method = env->GetMethodID(env->GetObjectClass(clazz), "onNativeUnaryCall", "(Ljava/lang/String;[B)L" BUILD_NAMESPACE "/NativeRequestData;"); + native_lib_on_asset_load = env->GetMethodID(env->GetObjectClass(clazz), "shouldLoadAsset", "(Ljava/lang/String;)Z"); // load libclient.so util::load_library(env, classloader, "client"); @@ -128,6 +147,7 @@ void JNICALL init(JNIEnv *env, jobject clazz, jobject classloader) { LOGE("can't find unaryCall signature"); } + DobbyHook((void *) AAssetManager_open, (void *) AAssetManager_open_hook, (void **) &AAssetManager_open_original); LOGD("Native initialized"); } @@ -137,6 +157,7 @@ void JNICALL load_config(JNIEnv *env, jobject _, jobject config_object) { native_config->disable_bitmoji = GET_CONFIG_BOOL("disableBitmoji"); native_config->disable_metrics = GET_CONFIG_BOOL("disableMetrics"); + native_config->hook_asset_open = GET_CONFIG_BOOL("hookAssetOpen"); } extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *_) { diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt index 4df1b5261..44b0118a6 100644 --- a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt +++ b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeConfig.kt @@ -2,5 +2,6 @@ package me.rhunk.snapenhance.nativelib data class NativeConfig( val disableBitmoji: Boolean = false, - val disableMetrics: Boolean = false + val disableMetrics: Boolean = false, + val hookAssetOpen: Boolean = false, ) \ No newline at end of file diff --git a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt index 68a0b9749..7385eecf6 100644 --- a/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt +++ b/native/src/main/kotlin/me/rhunk/snapenhance/nativelib/NativeLib.kt @@ -4,6 +4,8 @@ import android.util.Log class NativeLib { var nativeUnaryCallCallback: (NativeRequestData) -> Unit = {} + var nativeShouldLoadAsset: (String) -> Boolean = { true } + companion object { var initialized = false private set @@ -33,6 +35,11 @@ class NativeLib { return null } + @Suppress("unused") + private fun shouldLoadAsset(name: String) = runCatching { + nativeShouldLoadAsset(name) + }.getOrNull() ?: true + fun loadNativeConfig(config: NativeConfig) { if (!initialized) return loadConfig(config) From 9c5a590a6088aab4d9a623c732b47757c638a684 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:51:46 +0100 Subject: [PATCH 255/274] feat(core): strip media metadata --- common/src/main/assets/lang/en_US.json | 11 +++++ .../common/config/impl/MessagingTweaks.kt | 1 + .../features/impl/messaging/SendOverride.kt | 49 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 56e12bd51..7d7bb9bc4 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -393,6 +393,10 @@ "name": "Gallery Media Send Override", "description": "Spoofs the media source when sending from the Gallery" }, + "strip_media_metadata": { + "name": "Strip Media Metadata", + "description": "Removes metadata of media before sending as a message" + }, "bypass_message_retention_policy": { "name": "Bypass Message Retention Policy", "description": "Prevents messages from being deleted after viewing them" @@ -716,6 +720,13 @@ "SNAP": "Snap", "SAVABLE_SNAP": "Savable Snap" }, + "strip_media_metadata": { + "hide_caption_text": "Hide Caption Text", + "hide_snap_filters": "Hide Snap Filters", + "hide_extras": "Hide Extras (e.g. mentions)", + "remove_audio_note_duration": "Remove Audio Note Duration", + "remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability" + }, "hide_ui_components": { "hide_profile_call_buttons": "Remove Profile Call Buttons", "hide_chat_call_buttons": "Remove Chat Call Buttons", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index eb522a171..19beb02f7 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -34,5 +34,6 @@ class MessagingTweaks : ConfigContainer() { } val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val galleryMediaSendOverride = boolean("gallery_media_send_override") { nativeHooks() } + val stripMediaMetadata = multiple("strip_media_metadata", "hide_caption_text", "hide_snap_filters", "hide_extras", "remove_audio_note_duration", "remove_audio_note_transcript_capability") { requireRestart() } val bypassMessageRetentionPolicy = boolean("bypass_message_retention_policy") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt index 4170c5720..aef2218df 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -50,6 +50,55 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI event.buffer = protoEditor.toByteArray() } + val stripSnapMetadata = context.config.messaging.stripMediaMetadata.get() + + context.event.subscribe(SendMessageWithContentEvent::class, { + stripSnapMetadata.isNotEmpty() + }) { event -> + val contentType = event.messageContent.contentType ?: return@subscribe + + val newMessageContent = ProtoEditor(event.messageContent.content!!).apply { + when (contentType) { + ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> { + edit(*(if (contentType == ContentType.SNAP) intArrayOf(11) else intArrayOf(3, 3))) { + if (stripSnapMetadata.contains("hide_caption_text")) { + edit(5) { + editEach(1) { + remove(2) + } + } + } + if (stripSnapMetadata.contains("hide_snap_filters")) { + remove(9) + remove(11) + } + if (stripSnapMetadata.contains("hide_extras")) { + remove(13) + edit(5, 1) { + remove(2) + } + } + } + } + ContentType.NOTE -> { + if (stripSnapMetadata.contains("remove_audio_note_duration")) { + edit(6, 1, 1) { + remove(13) + } + } + if (stripSnapMetadata.contains("remove_audio_note_transcript_capability")) { + edit(6, 1) { + remove(3) + } + } + } + else -> return@subscribe + } + }.toByteArray() + + event.messageContent.content = newMessageContent + } + context.event.subscribe(SendMessageWithContentEvent::class, { context.config.messaging.galleryMediaSendOverride.get() }) { event -> From 3b0b44fcd44fa67e7a7eacc5c61a003975230191 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 27 Nov 2023 19:26:39 +0100 Subject: [PATCH 256/274] feat: bulk messaging actions --- common/src/main/assets/lang/en_US.json | 17 +++-- .../snapenhance/common/action/EnumAction.kt | 2 +- ...emoveFriends.kt => BulkMessagingAction.kt} | 71 +++++++++++++++---- .../core/features/impl/messaging/Messaging.kt | 21 ++++++ .../core/manager/impl/ActionManager.kt | 4 +- .../core/messaging/EnumBulkAction.kt | 8 +++ .../core/wrapper/impl/ConversationManager.kt | 20 ++++++ 7 files changed, 121 insertions(+), 22 deletions(-) rename core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/{BulkRemoveFriends.kt => BulkMessagingAction.kt} (62%) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 7d7bb9bc4..00486d4f6 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -116,7 +116,7 @@ "open_map": "Choose location on map", "check_for_updates": "Check for updates", "export_chat_messages": "Export Chat Messages", - "bulk_remove_friends": "Bulk Remove Friends" + "bulk_messaging_action": "Bulk Messaging Action" }, "features": { @@ -865,14 +865,17 @@ "incoming_follower": "Incoming Follower" }, - "bulk_remove_friends": { - "title": "Bulk Remove Friend", - "progress_status": "Removing friends {index} of {total}", - "selection_dialog_title": "Select friends to remove", - "selection_dialog_remove_button": "Remove Selection", + "bulk_messaging_action": { + "choose_action_title": "Choose an action", + "progress_status": "Processing {index} of {total}", + "selection_dialog_continue_button": "Continue", "confirmation_dialog": { "title": "Are you sure?", - "message": "This will remove all selected friends. This action cannot be undone." + "message": "This will affect all selected friends. This action cannot be undone." + }, + "actions": { + "remove_friends": "Remove Friends", + "clear_conversations": "Clear Conversations" } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt index 0a79bb03b..415118a48 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -9,7 +9,7 @@ enum class EnumAction( ) { CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true), EXPORT_CHAT_MESSAGES("export_chat_messages"), - BULK_REMOVE_FRIENDS("bulk_remove_friends"); + BULK_MESSAGING_ACTION("bulk_messaging_action"); companion object { const val ACTION_PARAMETER = "se_action" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt similarity index 62% rename from core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt rename to core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt index 426034bb4..b4af2f4c7 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkRemoveFriends.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt @@ -4,16 +4,19 @@ import android.widget.ProgressBar import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.core.action.AbstractAction import me.rhunk.snapenhance.core.features.impl.experiments.AddFriendSourceSpoof +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.messaging.EnumBulkAction import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -class BulkRemoveFriends : AbstractAction() { - private val translation by lazy { context.translation.getCategory("bulk_remove_friends") } +class BulkMessagingAction : AbstractAction() { + private val translation by lazy { context.translation.getCategory("bulk_messaging_action") } - private fun removeFriends(friendIds: List) { + private fun removeAction(ids: List, action: (String) -> Unit = {}) { var index = 0 val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle("...") @@ -22,17 +25,17 @@ class BulkRemoveFriends : AbstractAction() { .show() context.coroutineScope.launch { - friendIds.forEach { userId -> + ids.forEach { id -> runCatching { - removeFriend(userId) + action(id) }.onFailure { - context.log.error("Failed to remove friend $it", it) - context.shortToast("Failed to remove friend $userId") + context.log.error("Failed to process $it", it) + context.shortToast("Failed to process $id") } index++ withContext(Dispatchers.Main) { dialog.setTitle( - translation.format("progress_status", "index" to index.toString(), "total" to friendIds.size.toString()) + translation.format("progress_status", "index" to index.toString(), "total" to ids.size.toString()) ) } delay(500) @@ -43,6 +46,20 @@ class BulkRemoveFriends : AbstractAction() { } } + private suspend fun askActionType() = suspendCancellableCoroutine { cont -> + context.runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(translation["choose_action_title"]) + .setItems(EnumBulkAction.entries.map { translation["actions.${it.key}"] }.toTypedArray()) { _, which -> + cont.resumeWith(Result.success(EnumBulkAction.entries[which])) + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .show() + } + } + private fun confirmationDialog(onConfirm: () -> Unit) { ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) .setTitle(translation["confirmation_dialog.title"]) @@ -62,6 +79,8 @@ class BulkRemoveFriends : AbstractAction() { ) context.coroutineScope.launch(Dispatchers.Main) { + val bulkAction = askActionType() ?: return@launch + val friends = context.database.getAllFriends().filter { it.userId !in userIdBlacklist && it.addedTimestamp != -1L && @@ -74,7 +93,7 @@ class BulkRemoveFriends : AbstractAction() { val selectedFriends = mutableListOf() ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(translation["selection_dialog_title"]) + .setTitle(translation["actions.${bulkAction.key}"]) .setMultiChoiceItems(friends.map { friend -> (friend.displayName?.let { "$it (${friend.mutableUsername})" @@ -87,16 +106,44 @@ class BulkRemoveFriends : AbstractAction() { selectedFriends.remove(friends[which].userId) } } - .setPositiveButton(translation["selection_dialog_remove_button"]) { _, _ -> + .setPositiveButton(translation["selection_dialog_continue_button"]) { _, _ -> confirmationDialog { - removeFriends(selectedFriends) + when (bulkAction) { + EnumBulkAction.REMOVE_FRIENDS -> { + removeAction(selectedFriends) { + removeFriend(it) + } + } + EnumBulkAction.CLEAR_CONVERSATIONS -> clearConversations(selectedFriends) + } } } - .setNegativeButton(context.translation["button.cancel"]) { _, _ -> } + .setNegativeButton(context.translation["button.cancel"]) { dialog, _ -> + dialog.dismiss() + } + .setCancelable(false) .show() } } + private fun clearConversations(friendIds: List) { + val messaging = context.feature(Messaging::class) + + messaging.conversationManager?.apply { + getOneOnOneConversationIds(friendIds, onError = { error -> + context.shortToast("Failed to fetch conversations: $error") + }, onSuccess = { conversations -> + context.runOnUiThread { + removeAction(conversations.map { it.second }.distinct()) { + messaging.clearConversationFromFeed(it, onError = { error -> + context.shortToast("Failed to clear conversation: $error") + }) + } + } + }) + } + } + private fun removeFriend(userId: String) { val friendRelationshipChangerMapping = context.mappings.getMappedMap("FriendRelationshipChanger") val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!! diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index d57fd1527..ddc6b78a4 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -15,10 +15,12 @@ import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull import me.rhunk.snapenhance.core.wrapper.impl.ConversationManager import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { var conversationManager: ConversationManager? = null private set + private var conversationManagerDelegate: Any? = null var openedConversationUUID: SnapUUID? = null private set var lastFetchConversationUserUUID: SnapUUID? = null @@ -43,6 +45,22 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C fun getFeedCachedMessageIds(conversationId: String) = feedCachedSnapMessages[conversationId] + fun clearConversationFromFeed(conversationId: String, onError : (String) -> Unit = {}, onSuccess : () -> Unit = {}) { + conversationManager?.clearConversation(conversationId, onError = { onError(it) }, onSuccess = { + runCatching { + conversationManagerDelegate!!.let { + it::class.java.methods.first { method -> + method.name == "onConversationRemoved" + }.invoke(conversationManagerDelegate, conversationId.toSnapUUID().instanceNonNull()) + } + onSuccess() + }.onFailure { + context.log.error("Failed to invoke onConversationRemoved: $it") + onError(it.message ?: "Unknown error") + } + }) + } + override fun onActivityCreate() { context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> @@ -57,6 +75,9 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } + context.mappings.getMappedClass("callbacks", "ConversationManagerDelegate").hookConstructor(HookStage.AFTER) { param -> + conversationManagerDelegate = param.thisObject() + } context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt index f3d3fc5e9..56af46a91 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt @@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.manager.impl import android.content.Intent import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.action.impl.BulkRemoveFriends +import me.rhunk.snapenhance.core.action.impl.BulkMessagingAction import me.rhunk.snapenhance.core.action.impl.CleanCache import me.rhunk.snapenhance.core.action.impl.ExportChatMessages import me.rhunk.snapenhance.core.manager.Manager @@ -16,7 +16,7 @@ class ActionManager( mapOf( EnumAction.CLEAN_CACHE to CleanCache::class, EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class, - EnumAction.BULK_REMOVE_FRIENDS to BulkRemoveFriends::class, + EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction::class, ).map { it.key to it.value.java.getConstructor().newInstance().apply { this.context = modContext diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt new file mode 100644 index 000000000..66a317fdf --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/EnumBulkAction.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.core.messaging + +enum class EnumBulkAction( + val key: String, +) { + REMOVE_FRIENDS("remove_friends"), + CLEAR_CONVERSATIONS("clear_conversations"), +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt index d5f20e22f..730ff8acf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ConversationManager.kt @@ -22,6 +22,8 @@ class ConversationManager( private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") } private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } private val fetchMessage by lazy { findMethodByName("fetchMessage") } + private val clearConversation by lazy { findMethodByName("clearConversation") } + private val getOneOnOneConversationIds by lazy { findMethodByName("getOneOnOneConversationIds") } fun updateMessage(conversationId: String, messageId: Long, action: MessageUpdate, onResult: CallbackResult = {}) { @@ -128,4 +130,22 @@ class ConversationManager( }.build() ) } + + fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(context.mappings.getMappedClass("callbacks", "Callback")) + .override("onSuccess") { onSuccess() } + .override("onError") { onError(it.arg(0).toString()) }.build() + clearConversation.invoke(instanceNonNull(), conversationId.toSnapUUID().instanceNonNull(), callback) + } + + fun getOneOnOneConversationIds(userIds: List, onSuccess: (List>) -> Unit, onError: (error: String) -> Unit) { + val callback = CallbackBuilder(context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback")) + .override("onSuccess") { param -> + onSuccess(param.arg>(0).map { + SnapUUID(it.getObjectField("mUserId")).toString() to SnapUUID(it.getObjectField("mConversationId")).toString() + }) + } + .override("onError") { onError(it.arg(0).toString()) }.build() + getOneOnOneConversationIds.invoke(instanceNonNull(), userIds.map { it.toSnapUUID().instanceNonNull() }.toMutableList(), callback) + } } \ No newline at end of file From b232dbc0563d01c2d93fd5e37eb9a75d27c0687b Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 27 Nov 2023 21:19:34 +0100 Subject: [PATCH 257/274] feat: fidelius indicator --- common/src/main/assets/lang/en_US.json | 4 ++ .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../features/impl/ui/FideliusIndicator.kt | 42 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 1 + 4 files changed, 48 insertions(+) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FideliusIndicator.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 00486d4f6..e4be795ea 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -314,6 +314,10 @@ "enable_friend_feed_menu_bar": { "name": "Friend Feed Menu Bar", "description": "Enables the new Friend Feed Menu Bar" + }, + "fidelius_indicator": { + "name": "Fidelius Indicator", + "description": "Adds a green circle next to messages that have been sent only to you" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index 052fab0ba..b1d0bcf01 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -47,4 +47,5 @@ class UserInterfaceTweaks : ConfigContainer() { val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } + val fideliusIndicator = boolean("fidelius_indicator") { requireRestart() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FideliusIndicator.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FideliusIndicator.kt new file mode 100644 index 000000000..be2f39450 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FideliusIndicator.kt @@ -0,0 +1,42 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable + +class FideliusIndicator : Feature("Fidelius Indicator", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + if (!context.config.userInterface.fideliusIndicator.get()) return + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { _, messageId -> + event.view.removeForegroundDrawable("fideliusIndicator") + + val message = context.database.getConversationMessageFromId(messageId.toLong()) ?: return@chatMessage + if (message.senderId == context.database.myUserId) return@chatMessage + if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage + + if (!ProtoReader(message.messageContent ?: return@chatMessage).containsPath(4, 3, 3, 6)) return@chatMessage + + event.view.addForegroundDrawable("fideliusIndicator", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + val margin = 25f + val radius = 15f + + canvas.drawCircle(margin + radius, canvas.height - margin - radius, radius, paint.apply { + color = 0xFF00FF00.toInt() + }) + } + })) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 38ff29689..5e8a6af1a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -110,6 +110,7 @@ class FeatureManager( DisableConfirmationDialogs::class, Stories::class, DisableComposerModules::class, + FideliusIndicator::class, ) initializeFeatures() From 78c28a8c9ed86269b81bd818e84e130d7927fe13 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:13:24 +0100 Subject: [PATCH 258/274] fix(core/ui): action menu container size --- .../core/ui/menu/impl/ChatActionMenu.kt | 16 ++++++++++++++++ .../core/ui/menu/impl/MenuViewInjector.kt | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt index f9ddfa2a6..6e0895fdf 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -18,6 +18,8 @@ import me.rhunk.snapenhance.core.ui.ViewTagState import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getDimens import java.time.Instant @@ -61,6 +63,20 @@ class ChatActionMenu : AbstractMenu() { private val lastFocusedMessage get() = context.database.getConversationMessageFromId(context.feature(Messaging::class).lastFocusedMessageId) + override fun init() { + runCatching { + if (!context.config.downloader.chatDownloadContextMenu.get() && !context.config.messaging.messageLogger.get() && !context.isDeveloper) return + context.androidContext.classLoader.loadClass("com.snap.messaging.chat.features.actionmenu.ActionMenuChatItemContainer") + .hook("onMeasure", HookStage.BEFORE) { param -> + param.setArg(1, + View.MeasureSpec.makeMeasureSpec((context.resources.displayMetrics.heightPixels * 0.35).toInt(), View.MeasureSpec.AT_MOST) + ) + } + }.onFailure { + context.log.error("Failed to hook ActionMenuChatItemContainer: $it") + } + } + @SuppressLint("SetTextI18n", "DiscouragedApi", "ClickableViewAccessibility") override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { val viewGroup = parent.parent.parent as? ViewGroup ?: return diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt index f8d68cd90..97d5be2a8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/MenuViewInjector.kt @@ -34,7 +34,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar menuMap[ChatActionMenu::class] = ChatActionMenu() menuMap[SettingsMenu::class] = SettingsMenu() - menuMap.values.forEach { it.context = context } + menuMap.values.forEach { it.context = context; it.init() } val messaging = context.feature(Messaging::class) From 4046d1a50658add099fb33396ff1a93fed012ac5 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 29 Nov 2023 22:35:46 +0100 Subject: [PATCH 259/274] feat(core/mark_as_seen): close menu on click - sort rule features --- .../rhunk/snapenhance/common/data/MessagingCoreObjects.kt | 2 +- .../rhunk/snapenhance/core/manager/impl/FeatureManager.kt | 2 +- .../me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt | 5 +++++ .../snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt | 6 +++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt index 89cd09bb9..ef7eccd98 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt @@ -31,8 +31,8 @@ enum class MessagingRuleType( val listMode: Boolean, val showInFriendMenu: Boolean = true ) { - AUTO_DOWNLOAD("auto_download", true), STEALTH("stealth", true), + AUTO_DOWNLOAD("auto_download", true), AUTO_SAVE("auto_save", true), HIDE_FRIEND_FEED("hide_friend_feed", false, showInFriendMenu = false), E2E_ENCRYPTION("e2e_encryption", false), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 5e8a6af1a..c98db39fd 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -58,7 +58,7 @@ class FeatureManager( return features.find { it::class == featureClass } as? T } - fun getRuleFeatures() = features.filterIsInstance() + fun getRuleFeatures() = features.filterIsInstance().sortedBy { it.ruleType.ordinal } override fun init() { register( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt index 2d8590c99..3e69470bc 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.core.ui import android.annotation.SuppressLint +import android.app.Activity import android.app.AlertDialog import android.content.Context import android.content.res.ColorStateList @@ -70,6 +71,10 @@ fun View.triggerCloseTouchEvent() { } } +fun Activity.triggerRootCloseTouchEvent() { + findViewById(android.R.id.content).triggerCloseTouchEvent() +} + fun ViewGroup.children(): List { val children = mutableListOf() for (i in 0 until childCount) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index 5b89da5b7..9c8e83a43 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -28,6 +28,7 @@ import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent import java.net.HttpURLConnection import java.net.URL import java.text.DateFormat @@ -305,7 +306,10 @@ class FriendFeedInfoMenu : AbstractMenu() { viewConsumer(Button(view.context).apply { text = modContext.translation["friend_menu_option.mark_as_seen"] applyTheme(view.width, hasRadius = true) - setOnClickListener { markAsSeen(conversationId) } + setOnClickListener { + this@FriendFeedInfoMenu.context.mainActivity?.triggerRootCloseTouchEvent() + markAsSeen(conversationId) + } }) } } From f375f9bc0e1f6ca77e27473464e4b81af1d094ff Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 29 Nov 2023 23:56:55 +0100 Subject: [PATCH 260/274] fix(downloader): media identifier & dash chapter selector - fix ffmpeg crashes - fix close resources - perf http server --- .../snapenhance/download/DownloadProcessor.kt | 71 +++++++----- .../snapenhance/download/FFMpegProcessor.kt | 2 + .../common/config/impl/DownloaderConfig.kt | 2 +- .../common/data/download/DownloadMetadata.kt | 2 +- .../snapenhance/core/DownloadManagerClient.kt | 6 +- .../snapenhance/core/features/impl/Stories.kt | 7 +- .../impl/downloader/MediaDownloader.kt | 53 +++++---- .../snapenhance/core/util/media/HttpServer.kt | 109 ++++++++++-------- 8 files changed, 144 insertions(+), 108 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt index bde5e7143..17605a069 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -122,10 +122,9 @@ class DownloadProcessor ( } pendingTask.updateProgress("Converting image to $format") - val outputStream = inputFile.outputStream() - bitmap.compress(compressFormat, 100, outputStream) - outputStream.close() - + inputFile.outputStream().use { + bitmap.compress(compressFormat, 100, it) + } fileType = FileType.fromFile(inputFile) } } @@ -146,11 +145,12 @@ class DownloadProcessor ( } val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!! - val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!! pendingTask.updateProgress("Saving media to gallery") - inputFile.inputStream().use { inputStream -> - inputStream.copyTo(outputStream) + remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream -> + inputFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } } pendingTask.task.extra = outputFile.uri.toString() @@ -201,19 +201,20 @@ class DownloadProcessor ( fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) { createMediaTempFile().apply { val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered() - val outputStream = outputStream() val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var read: Int var totalRead = 0L var lastTotalRead = 0L - while (decryptedInputStream.read(buffer).also { read = it } != -1) { - outputStream.write(buffer, 0, read) - totalRead += read - inputMediaDownloadedBytes[inputMedia] = totalRead - if (totalRead - lastTotalRead > 1024 * 1024) { - setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") - lastTotalRead = totalRead + outputStream().use { outputStream -> + while (decryptedInputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + totalRead += read + inputMediaDownloadedBytes[inputMedia] = totalRead + if (totalRead - lastTotalRead > 1024 * 1024) { + setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB") + lastTotalRead = totalRead + } } } }.also { downloadedMedias[inputMedia] = it } @@ -224,7 +225,9 @@ class DownloadProcessor ( DownloadMediaType.PROTO_MEDIA -> { RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length -> totalSize += length - handleInputStream(inputStream, estimatedSize = length) + inputStream.use { + handleInputStream(it, estimatedSize = length) + } }) } DownloadMediaType.REMOTE_MEDIA -> { @@ -233,7 +236,9 @@ class DownloadProcessor ( setRequestProperty("User-Agent", Constants.USER_AGENT) connect() totalSize += contentLength.toLong() - handleInputStream(inputStream, estimatedSize = contentLength.toLong()) + inputStream.use { + handleInputStream(it, estimatedSize = contentLength.toLong()) + } } } DownloadMediaType.DIRECT_MEDIA -> { @@ -292,8 +297,9 @@ class DownloadProcessor ( val dashOptions = downloadRequest.dashOptions!! val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) - val xmlData = dashPlaylistFile.outputStream() - TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) + dashPlaylistFile.outputStream().use { + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it)) + } callbackOnProgress(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension)) val outputFile = File.createTempFile("dash", ".mp4") @@ -329,9 +335,8 @@ class DownloadProcessor ( remoteSideContext.coroutineScope.launch { val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java) val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java) - val downloadId = (downloadMetadata.mediaIdentifier ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16) - remoteSideContext.taskManager.getTaskByHash(downloadId)?.let { task -> + remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task -> remoteSideContext.log.debug("already queued or downloaded") if (task.status.isFinalStage()) { @@ -348,7 +353,7 @@ class DownloadProcessor ( Task( type = TaskType.DOWNLOAD, title = downloadMetadata.downloadSource + " (" + downloadMetadata.mediaAuthor + ")", - hash = downloadId + hash = downloadMetadata.mediaIdentifier ) ).apply { status = TaskStatus.RUNNING @@ -372,15 +377,19 @@ class DownloadProcessor ( val oldDownloadedMedias = downloadedMedias.toMap() downloadedMedias.clear() - MediaDownloaderHelper.getSplitElements(zipFile.file.inputStream()) { type, inputStream -> - createMediaTempFile().apply { - inputStream.copyTo(outputStream()) - }.also { - downloadedMedias[InputMedia( - type = DownloadMediaType.LOCAL_MEDIA, - content = it.absolutePath, - isOverlay = type == SplitMediaAssetType.OVERLAY - )] = DownloadedFile(it, FileType.fromFile(it)) + zipFile.file.inputStream().use { zipFileInputStream -> + MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream -> + createMediaTempFile().apply { + outputStream().use { + inputStream.copyTo(it) + } + }.also { + downloadedMedias[InputMedia( + type = DownloadMediaType.LOCAL_MEDIA, + content = it.absolutePath, + isOverlay = type == SplitMediaAssetType.OVERLAY + )] = DownloadedFile(it, FileType.fromFile(it)) + } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt index 247ec36d7..58ad1988a 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -94,6 +94,8 @@ class FFMpegProcessor( } suspend fun execute(args: Request) { + // load ffmpeg native sync to avoid native crash + synchronized(this) { FFmpegKit.listSessions() } val globalArguments = ArgumentList().apply { this += "-y" this += "-threads" to ffmpegOptions.threads.get().toString() diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt index d0c73d510..1e4e7c52b 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/DownloaderConfig.kt @@ -46,6 +46,6 @@ class DownloaderConfig : ConfigContainer() { val chatDownloadContextMenu = boolean("chat_download_context_menu") val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { - set(mutableListOf("started", "success")) + set(mutableListOf("success", "progress", "failure")) } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt index 6c13dc625..29a97c58f 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMetadata.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.common.data.download data class DownloadMetadata( - val mediaIdentifier: String?, + val mediaIdentifier: String, val outputPath: String, val mediaAuthor: String?, val downloadSource: String, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt index 42fec8ca4..1462e3239 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt @@ -26,9 +26,9 @@ class DownloadManagerClient ( DownloadRequest( inputMedias = arrayOf( InputMedia( - content = playlistUrl, - type = DownloadMediaType.REMOTE_MEDIA - ) + content = playlistUrl, + type = DownloadMediaType.REMOTE_MEDIA + ) ), dashOptions = DashOptions(offsetTime, duration), flags = DownloadRequest.Flags.IS_DASH_PLAYLIST diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt index 390de4e0e..fc32fd678 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt @@ -17,8 +17,11 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.ACTIVITY_CREAT fun cancelRequest() { runBlocking { suspendCoroutine { - context.httpServer.ensureServerStarted { - event.url = "http://127.0.0.1:${context.httpServer.port}" + context.httpServer.ensureServerStarted()?.let { server -> + event.url = "http://127.0.0.1:${server.port}" + it.resumeWith(Result.success(Unit)) + } ?: run { + event.canceled = true it.resumeWith(Result.success(Unit)) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt index 341b9272a..dde9931e2 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -25,6 +25,7 @@ import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper @@ -54,9 +55,11 @@ import java.io.ByteArrayInputStream import java.nio.file.Paths import java.text.SimpleDateFormat import java.util.Locale +import java.util.UUID import kotlin.coroutines.suspendCoroutine import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.absoluteValue private fun String.sanitizeForPath(): String { return this.replace(" ", "_") @@ -85,7 +88,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null ): DownloadManagerClient { - val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "") + val generatedHash = ( + if (!context.config.downloader.allowDuplicate.get()) mediaIdentifier + else UUID.randomUUID().toString() + ).longHashCode().absoluteValue.toString(16) + val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.THREE_D) val downloadLogging by context.config.downloader.logging @@ -98,9 +105,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return DownloadManagerClient( context = context, metadata = DownloadMetadata( - mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { - generatedHash - } else null, + mediaIdentifier = generatedHash, mediaAuthor = mediaAuthor, downloadSource = downloadSource.key, iconUrl = iconUrl, @@ -161,7 +166,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp finalPath.append(downloadSource.pathName).append("/") } if (pathFormat.contains("append_hash")) { - appendFileName(hexHash) + appendFileName(hexHash.substring(0, hexHash.length.coerceAtMost(8))) } if (pathFormat.contains("append_source")) { appendFileName(downloadSource.pathName) @@ -228,10 +233,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp Uri.parse(path).let { uri -> if (uri.scheme == "file") { return@let suspendCoroutine { continuation -> - context.httpServer.ensureServerStarted { + context.httpServer.ensureServerStarted()?.let { server -> val file = Paths.get(uri.path).toFile() - val url = putDownloadableContent(file.inputStream(), file.length()) + val url = server.putDownloadableContent(file.inputStream(), file.length()) continuation.resumeWith(Result.success(url)) + } ?: run { + continuation.resumeWith(Result.failure(Exception("Failed to start http server"))) } } } @@ -426,7 +433,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp setTitle("Download dash media") setMultiChoiceItems( chapters.map { "Segment ${prettyPrintTime(it.offset)} - ${prettyPrintTime(it.offset + (it.duration ?: 0))}" }.toTypedArray(), - List(chapters.size) { index -> currentChapterIndex == index }.toBooleanArray() + List(chapters.size) { index -> + if (currentChapterIndex == index) { + selectedChapters.add(index) + true + } else false + }.toBooleanArray() ) { _, which, isChecked -> if (isChecked) { selectedChapters.add(which) @@ -444,22 +456,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } setPositiveButton("Download") { _, _ -> val groups = mutableListOf>() - var currentGroup = mutableListOf() - var lastChapterIndex = -1 - //check for consecutive chapters - chapters.filterIndexed { index, _ -> selectedChapters.contains(index) } - .forEachIndexed { index, pair -> - if (lastChapterIndex != -1 && index != lastChapterIndex + 1) { - groups.add(currentGroup) - currentGroup = mutableListOf() + var lastChapterIndex = -1 + // group consecutive chapters + chapters.forEachIndexed { index, snapChapter -> + lastChapterIndex = if (selectedChapters.contains(index)) { + if (lastChapterIndex == -1) { + groups.add(mutableListOf()) } - currentGroup.add(pair) - lastChapterIndex = index - } - - if (currentGroup.isNotEmpty()) { - groups.add(currentGroup) + groups.last().add(snapChapter) + index + } else { + -1 + } } groups.forEach { group -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt index be947f3ba..cac788b59 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/media/HttpServer.kt @@ -1,10 +1,6 @@ package me.rhunk.snapenhance.core.util.media -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import me.rhunk.snapenhance.common.logger.AbstractLogger import java.io.BufferedReader import java.io.InputStream @@ -16,12 +12,16 @@ import java.net.SocketException import java.util.Locale import java.util.StringTokenizer import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.suspendCoroutine import kotlin.random.Random class HttpServer( private val timeout: Int = 10000 ) { - val port = Random.nextInt(10000, 65535) + private fun newRandomPort() = Random.nextInt(10000, 65535) + + var port = newRandomPort() + private set private val coroutineScope = CoroutineScope(Dispatchers.IO) private var timeoutJob: Job? = null @@ -30,42 +30,56 @@ class HttpServer( private val cachedData = ConcurrentHashMap>() private var serverSocket: ServerSocket? = null - fun ensureServerStarted(callback: HttpServer.() -> Unit) { - if (serverSocket != null && !serverSocket!!.isClosed) { - callback(this) - return - } + fun ensureServerStarted(): HttpServer? { + if (serverSocket != null && serverSocket?.isClosed != true) return this + + return runBlocking { + withTimeoutOrNull(5000L) { + suspendCoroutine { continuation -> + coroutineScope.launch(Dispatchers.IO) { + AbstractLogger.directDebug("Starting http server on port $port") + for (i in 0..5) { + try { + serverSocket = ServerSocket(port) + break + } catch (e: Throwable) { + AbstractLogger.directError("failed to start http server on port $port", e) + port = newRandomPort() + } + } + continuation.resumeWith(Result.success(if (serverSocket == null) null.also { + return@launch + } else this@HttpServer)) - coroutineScope.launch(Dispatchers.IO) { - AbstractLogger.directDebug("starting http server on port $port") - serverSocket = ServerSocket(port) - callback(this@HttpServer) - while (!serverSocket!!.isClosed) { - try { - val socket = serverSocket!!.accept() - timeoutJob?.cancel() - launch { - handleRequest(socket) - timeoutJob = launch { - delay(timeout.toLong()) - AbstractLogger.directDebug("http server closed due to timeout") - runCatching { - socketJob?.cancel() - socket.close() - serverSocket?.close() - }.onFailure { - AbstractLogger.directError("failed to close socket", it) + while (!serverSocket!!.isClosed) { + try { + val socket = serverSocket!!.accept() + timeoutJob?.cancel() + launch { + handleRequest(socket) + timeoutJob = launch { + delay(timeout.toLong()) + AbstractLogger.directDebug("http server closed due to timeout") + runCatching { + socketJob?.cancel() + socket.close() + serverSocket?.close() + }.onFailure { + AbstractLogger.directError("failed to close socket", it) + } + } + } + } catch (e: SocketException) { + AbstractLogger.directDebug("http server timed out") + break; + } catch (e: Throwable) { + AbstractLogger.directError("failed to handle request", e) } } - } - } catch (e: SocketException) { - AbstractLogger.directDebug("http server timed out") - break; - } catch (e: Throwable) { - AbstractLogger.directError("failed to handle request", e) + }.also { socketJob = it } } } - }.also { socketJob = it } + } } fun close() { @@ -112,18 +126,15 @@ class HttpServer( if (fileRequested.startsWith("/")) { fileRequested = fileRequested.substring(1) } - if (!cachedData.containsKey(fileRequested)) { - with(writer) { - println("HTTP/1.1 404 Not Found") - println("Content-type: " + "application/octet-stream") - println("Content-length: " + 0) - println() - flush() - } + val requestedData = cachedData[fileRequested] ?: writer.run { + println("HTTP/1.1 404 Not Found") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() close() return } - val requestedData = cachedData[fileRequested]!! with(writer) { println("HTTP/1.1 200 OK") println("Content-type: " + "application/octet-stream") @@ -131,9 +142,11 @@ class HttpServer( println() flush() } - requestedData.first.copyTo(outputStream) - outputStream.flush() cachedData.remove(fileRequested) + requestedData.first.use { + it.copyTo(outputStream) + } + outputStream.flush() close() } } \ No newline at end of file From c04b99434a7389d28128d66d5fb51c8345eb8c41 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Dec 2023 18:05:45 +0100 Subject: [PATCH 261/274] fix(app): sync friend streaks --- .../main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt | 2 +- .../rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt index 66784342c..85ed56cc0 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -143,7 +143,7 @@ class ModDatabase( database.execSQL("INSERT OR REPLACE INTO streaks (userId, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf( friend.userId, - streaks?.notify ?: false, + streaks?.notify ?: true, friend.streakExpirationTimestamp, friend.streakLength )) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt index 9b38cfbdd..f6ebd2a1d 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/HomeSection.kt @@ -142,7 +142,7 @@ class HomeSection : Section() { title = if (installationSummary.modInfo == null || installationSummary.modInfo.mappingsOutdated == true) { "Mappings ${if (installationSummary.modInfo == null) "not generated" else "outdated"}" } else { - "Mappings version ${installationSummary.modInfo.mappingVersion}" + "Mappings are up-to-date" } ) { Button(onClick = { From 202638841a23b5a2d25d11398c9dd934658ac291 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Dec 2023 18:50:11 +0100 Subject: [PATCH 262/274] feat(core/notifications): save in chat when marking as read --- common/src/main/assets/lang/en_US.json | 1 + .../common/config/impl/MessagingTweaks.kt | 2 +- .../core/features/impl/messaging/AutoSave.kt | 20 +++++++------- .../features/impl/messaging/Notifications.kt | 26 +++++++++++++++++++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index e4be795ea..8b7f96539 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -671,6 +671,7 @@ "reply_button": "Add reply button", "download_button": "Add download button", "mark_as_read_button": "Mark as Read button", + "mark_as_read_and_save_in_chat": "Save in Chat when marking as read (depends on Auto Save)", "group": "Group notifications" }, "friend_feed_menu_buttons": { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 19beb02f7..797ffe750 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -28,7 +28,7 @@ class MessagingTweaks : ConfigContainer() { nativeHooks() } val instantDelete = boolean("instant_delete") { requireRestart() } - val betterNotifications = multiple("better_notifications", "chat_preview", "media_preview", "reply_button", "download_button", "mark_as_read_button", "group") { requireRestart() } + val betterNotifications = multiple("better_notifications", "chat_preview", "media_preview", "reply_button", "download_button", "mark_as_read_button", "mark_as_read_and_save_in_chat", "group") { requireRestart() } val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { customOptionTranslationPath = "features.options.notifications" } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt index 33629ede5..36bb1a975 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -15,7 +15,7 @@ import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID import java.util.concurrent.Executors -class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { +class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.INIT_SYNC) { private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() private val messageLogger by lazy { context.feature(MessageLogger::class) } @@ -25,7 +25,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, context.config.messaging.autoSaveMessagesInConversations.get() } - private fun saveMessage(conversationId: SnapUUID, message: Message) { + fun saveMessage(conversationId: SnapUUID, message: Message) { val messageId = message.messageDescriptor!!.messageId!! if (messageLogger.takeIf { it.isEnabled }?.isMessageDeleted(conversationId.toString(), messageId) == true) return if (message.messageState != MessageState.COMMITTED) return @@ -48,22 +48,22 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, Thread.sleep(100L) } - private fun canSaveMessage(message: Message): Boolean { - if (context.mainActivity == null || context.isMainActivityPaused) return false + fun canSaveMessage(message: Message, headless: Boolean = false): Boolean { + if (!headless && (context.mainActivity == null || context.isMainActivityPaused)) return false if (message.messageMetadata!!.savedBy!!.any { uuid -> uuid.toString() == context.database.myUserId }) return false val contentType = message.messageContent!!.contentType.toString() return autoSaveFilter.any { it == contentType } } - private fun canSaveInConversation(targetConversationId: String): Boolean { + fun canSaveInConversation(targetConversationId: String, headless: Boolean = false): Boolean { val messaging = context.feature(Messaging::class) - val openedConversationId = messaging.openedConversationUUID?.toString() ?: return false - - if (openedConversationId != targetConversationId) return false + if (!headless) { + if (messaging.openedConversationUUID?.toString() != targetConversationId) return false + } - if (context.feature(StealthMode::class).canUseRule(openedConversationId)) return false - if (!canUseRule(openedConversationId)) return false + if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false + if (!canUseRule(targetConversationId)) return false return true } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt index adceed78b..6202f9066 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Notifications.kt @@ -37,6 +37,7 @@ import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.util.media.PreviewUtils import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID import kotlin.coroutines.suspendCoroutine class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { @@ -231,6 +232,31 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN } ) + if (betterNotificationFilter.contains("mark_as_read_and_save_in_chat")) { + val messaging = context.feature(Messaging::class) + val autoSave = context.feature(AutoSave::class) + + if (autoSave.canSaveInConversation(conversationId, headless = true)) { + messaging.conversationManager?.fetchConversationWithMessagesPaginated( + conversationId, + Long.MAX_VALUE, + 20, + onSuccess = { messages -> + messages.reversed().forEach { message -> + if (!autoSave.canSaveMessage(message, headless = true)) return@forEach + context.coroutineScope.launch(coroutineDispatcher) { + autoSave.saveMessage(conversationId.toSnapUUID(), message) + } + } + }, + onError = { + context.log.error("Failed to fetch conversation: $it") + context.shortToast("Failed to fetch conversation") + } + ) + } + } + val conversationMessage = context.database.getConversationMessageFromId(clientMessageId) ?: return@subscribe if (conversationMessage.contentType == ContentType.SNAP.id) { From 98d0f987145f022f92cd1f637c0dff403bf7e3ff Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Fri, 1 Dec 2023 21:44:40 +0100 Subject: [PATCH 263/274] refactor: convert message locally --- common/src/main/assets/lang/en_US.json | 9 +-- .../common/config/impl/Experimental.kt | 2 +- .../impl/experiments/ConvertMessageLocally.kt | 67 +++++++++++++++++++ .../impl/experiments/SnapToChatMedia.kt | 25 ------- .../core/features/impl/messaging/Messaging.kt | 14 +++- .../core/manager/impl/FeatureManager.kt | 8 +-- .../core/ui/menu/impl/ChatActionMenu.kt | 28 ++++++++ 7 files changed, 117 insertions(+), 36 deletions(-) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt delete mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 8b7f96539..aea9d2669 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -581,9 +581,9 @@ } } }, - "snap_to_chat_media": { - "name": "Snap to Chat Media", - "description": "Converts snaps to chat external media" + "convert_message_locally": { + "name": "Convert Message Locally", + "description": "Converts snaps to chat external media locally. This appears in chat download context menu" }, "app_passcode": { "name": "App Passcode", @@ -813,7 +813,8 @@ "chat_action_menu": { "preview_button": "Preview", "download_button": "Download", - "delete_logged_message_button": "Delete Logged Message" + "delete_logged_message_button": "Delete Logged Message", + "convert_message": "Convert Message" }, "opera_context_menu": { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt index d1d74631a..38f4a9916 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -10,7 +10,7 @@ class Experimental : ConfigContainer() { val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } val spoof = container("spoof", Spoof()) { icon = "Fingerprint" } - val snapToChatMedia = boolean("snap_to_chat_media") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val appPasscode = string("app_passcode") val appLockOnResume = boolean("app_lock_on_resume") val infiniteStoryBoost = boolean("infinite_story_boost") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt new file mode 100644 index 000000000..bb95d6c85 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/ConvertMessageLocally.kt @@ -0,0 +1,67 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent + +class ConvertMessageLocally : Feature("Convert Message Edit", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val messageCache = mutableMapOf() + + private fun dispatchMessageEdit(message: Message, restore: Boolean = false) { + val messageId = message.messageDescriptor!!.messageId!! + if (!restore) messageCache[messageId] = message.messageContent!! + + context.runOnUiThread { + context.feature(Messaging::class).localUpdateMessage( + message.messageDescriptor!!.conversationId!!.toString(), + message + ) + } + } + + fun convertMessageInterface(messageInstance: Message) { + val actions = mutableMapOf Unit>() + actions["restore_original"] = { + messageCache.remove(it.messageDescriptor!!.messageId!!) + dispatchMessageEdit(it, restore = true) + } + + val contentType = messageInstance.messageContent?.contentType + if (contentType == ContentType.SNAP) { + actions["convert_external_media"] = convert@{ message -> + val snapMessageContent = ProtoReader(message.messageContent!!.content!!).followPath(11) + ?.getBuffer() ?: return@convert + message.messageContent!!.content = ProtoWriter().apply { + from(3) { + addBuffer(3, snapMessageContent) + } + }.toByteArray() + dispatchMessageEdit(message) + } + } + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + setItems(actions.keys.toTypedArray()) { _, which -> + actions.values.elementAt(which).invoke(messageInstance) + } + setPositiveButton(this@ConvertMessageLocally.context.translation["button.cancel"]) { dialog, _ -> + dialog.dismiss() + } + }.show() + } + + override fun onActivityCreate() { + context.event.subscribe(BuildMessageEvent::class, priority = 2) { + val clientMessageId = it.message.messageDescriptor?.messageId ?: return@subscribe + if (!messageCache.containsKey(clientMessageId)) return@subscribe + it.message.messageContent = messageCache[clientMessageId] + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt deleted file mode 100644 index ea56748ce..000000000 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SnapToChatMedia.kt +++ /dev/null @@ -1,25 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.experiments - -import me.rhunk.snapenhance.common.data.ContentType -import me.rhunk.snapenhance.common.util.protobuf.ProtoReader -import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter -import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent -import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.features.FeatureLoadParams - -class SnapToChatMedia : Feature("SnapToChatMedia", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - if (!context.config.experimental.snapToChatMedia.get()) return - - context.event.subscribe(BuildMessageEvent::class, priority = 100) { event -> - if (event.message.messageContent!!.contentType != ContentType.SNAP) return@subscribe - - val snapMessageContent = ProtoReader(event.message.messageContent!!.content!!).followPath(11)?.getBuffer() ?: return@subscribe - event.message.messageContent!!.content = ProtoWriter().apply { - from(3) { - addBuffer(3, snapMessageContent) - } - }.toByteArray() - } - } -} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt index ddc6b78a4..74af40e03 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -61,6 +61,14 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C }) } + fun localUpdateMessage(conversationId: String, message: Message) { + conversationManagerDelegate?.let { + it::class.java.methods.first { method -> + method.name == "onConversationUpdated" + }.invoke(conversationManagerDelegate, conversationId.toSnapUUID().instanceNonNull(), null, mutableListOf(message.instanceNonNull()), mutableListOf()) + } + } + override fun onActivityCreate() { context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> @@ -75,8 +83,10 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } } - context.mappings.getMappedClass("callbacks", "ConversationManagerDelegate").hookConstructor(HookStage.AFTER) { param -> - conversationManagerDelegate = param.thisObject() + context.mappings.getMappedClass("callbacks", "ConversationManagerDelegate").apply { + hookConstructor(HookStage.AFTER) { param -> + conversationManagerDelegate = param.thisObject() + } } context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index c98db39fd..fff27ee2d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -9,17 +9,17 @@ import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.MessagingRuleFeature import me.rhunk.snapenhance.core.features.impl.ConfigurationOverride import me.rhunk.snapenhance.core.features.impl.ScopeSync +import me.rhunk.snapenhance.core.features.impl.Stories import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.features.impl.downloader.ProfilePictureDownloader import me.rhunk.snapenhance.core.features.impl.experiments.* import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.messaging.* -import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.StealthMode -import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.core.features.impl.tweaks.BypassScreenshotDetection -import me.rhunk.snapenhance.core.features.impl.Stories +import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.core.features.impl.ui.* import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.manager.Manager @@ -70,6 +70,7 @@ class FeatureManager( MenuViewInjector::class, PreventReadReceipts::class, MessageLogger::class, + ConvertMessageLocally::class, SnapchatPlus::class, DisableMetrics::class, PreventMessageSending::class, @@ -97,7 +98,6 @@ class FeatureManager( AddFriendSourceSpoof::class, DisableReplayInFF::class, OldBitmojiSelfie::class, - SnapToChatMedia::class, FriendFeedMessagePreview::class, HideStreakRestore::class, HideFriendFeedEntry::class, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt index 6e0895fdf..81ba1ae3e 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -13,6 +13,7 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.core.features.impl.experiments.ConvertMessageLocally import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.ViewTagState import me.rhunk.snapenhance.core.ui.applyTheme @@ -147,6 +148,33 @@ class ChatActionMenu : AbstractMenu() { }) } + if (context.config.experimental.convertMessageLocally.get()) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.convert_message"] + setOnClickListener { + closeActionMenu() + messaging.conversationManager?.fetchMessage( + messaging.openedConversationUUID.toString(), + messaging.lastFocusedMessageId, + onSuccess = { + this@ChatActionMenu.context.runOnUiThread { + runCatching { + this@ChatActionMenu.context.feature(ConvertMessageLocally::class) + .convertMessageInterface(it) + }.onFailure { + this@ChatActionMenu.context.log.verbose("Failed to convert message: $it") + this@ChatActionMenu.context.shortToast("Failed to edit message: $it") + } + } + }, + onError = { + this@ChatActionMenu.context.shortToast("Failed to fetch message: $it") + } + ) + } + }) + } + if (context.isDeveloper) { viewGroup.addView(createContainer(viewGroup).apply { val debugText = StringBuilder() From bfe367efd0405408442f34f171497e3044e3220c Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sat, 2 Dec 2023 23:02:05 +0100 Subject: [PATCH 264/274] fix(mapper): content callback class --- .../me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt index 0176fb1a0..c48a2dcb1 100644 --- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt +++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CallbackMapper.kt @@ -4,6 +4,9 @@ import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getSuperClassName import me.rhunk.snapenhance.mapper.ext.isFinal +import org.jf.dexlib2.Opcode +import org.jf.dexlib2.iface.instruction.formats.Instruction21t +import org.jf.dexlib2.iface.instruction.formats.Instruction22t class CallbackMapper : AbstractClassMapper() { init { @@ -18,7 +21,9 @@ class CallbackMapper : AbstractClassMapper() { if (clazz.getClassName().endsWith("\$CppProxy")) return@filter false // ignore dummy ContentCallback class - if (superclassName.endsWith("ContentCallback") && !clazz.methods.first { it.name == "" }.parameterTypes.contains("Z")) + if (superclassName.endsWith("ContentCallback") && clazz.methods.none { it.name == "handleContentResult" && it.implementation?.instructions?.firstOrNull { instruction -> + instruction is Instruction22t || instruction is Instruction21t + } != null}) return@filter false val superClass = getClass(clazz.superclass) ?: return@filter false From 79be5da030dd2fa582ad3c7cb0bdd57181d072dc Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 12:42:57 +0100 Subject: [PATCH 265/274] feat: prevent message list auto scroll --- common/src/main/assets/lang/en_US.json | 4 + .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../tweaks/PreventMessageListAutoScroll.kt | 80 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 2 + 4 files changed, 87 insertions(+) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index aea9d2669..fcd293e3b 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -259,6 +259,10 @@ "name": "Enhanced Friend Map Nametags", "description": "Improves the Nametags of friends on the Snapmap" }, + "prevent_message_list_auto_scroll": { + "name": "Prevent Message List Auto Scroll", + "description": "Prevents the message list from scrolling to the bottom when sending/receiving a message" + }, "streak_expiration_info": { "name": "Show Streak Expiration Info", "description": "Shows a Streak Expiration timer next to the Streaks counter" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index b1d0bcf01..ddb80b2f9 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -29,6 +29,7 @@ class UserInterfaceTweaks : ConfigContainer() { val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } + val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() } val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt new file mode 100644 index 000000000..a52a18085 --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/tweaks/PreventMessageListAutoScroll.kt @@ -0,0 +1,80 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +import android.view.View +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class PreventMessageListAutoScroll : Feature("PreventMessageListAutoScroll", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private var openedConversationId: String? = null + private val focusedMessages = mutableMapOf() + private var firstFocusedMessageId: Long? = null + private val delayedMessageUpdates = mutableListOf<() -> Unit>() + + override fun onActivityCreate() { + if (!context.config.userInterface.preventMessageListAutoScroll.get()) return + + context.mappings.getMappedClass("callbacks", "ConversationManagerDelegate").hook("onConversationUpdated", HookStage.BEFORE) { param -> + val updatedMessage = param.arg>(2).map { Message(it) }.firstOrNull() ?: return@hook + if (openedConversationId != updatedMessage.messageDescriptor?.conversationId.toString()) return@hook + + // cancel if the message is already in focus + if (focusedMessages.entries.any { entry -> entry.value == updatedMessage.messageDescriptor?.messageId && entry.key.isAttachedToWindow }) return@hook + + val conversationLastMessages = context.database.getMessagesFromConversationId( + openedConversationId.toString(), + 4 + ) ?: return@hook + + if (conversationLastMessages.none { + focusedMessages.entries.any { entry -> entry.value == it.clientMessageId.toLong() && entry.key.isAttachedToWindow } + }) { + synchronized(delayedMessageUpdates) { + if (firstFocusedMessageId == null) firstFocusedMessageId = conversationLastMessages.lastOrNull()?.clientMessageId?.toLong() + delayedMessageUpdates.add { + param.invokeOriginal() + } + } + param.setResult(null) + } + } + + context.classCache.conversationManager.apply { + hook("enterConversation", HookStage.BEFORE) { param -> + openedConversationId = SnapUUID(param.arg(0)).toString() + } + hook("exitConversation", HookStage.BEFORE) { + openedConversationId = null + firstFocusedMessageId = null + synchronized(focusedMessages) { + focusedMessages.clear() + } + synchronized(delayedMessageUpdates) { + delayedMessageUpdates.clear() + } + } + } + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + if (conversationId != openedConversationId) return@chatMessage + synchronized(focusedMessages) { + focusedMessages[event.view] = messageId.toLong() + } + + if (delayedMessageUpdates.isNotEmpty() && focusedMessages.entries.any { entry -> entry.value == firstFocusedMessageId && entry.key.isAttachedToWindow }) { + delayedMessageUpdates.apply { + synchronized(this) { + removeIf { it(); true } + firstFocusedMessageId = null + } + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index fff27ee2d..e78c3301b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -20,6 +20,7 @@ import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.tweaks.BypassScreenshotDetection import me.rhunk.snapenhance.core.features.impl.tweaks.CameraTweaks +import me.rhunk.snapenhance.core.features.impl.tweaks.PreventMessageListAutoScroll import me.rhunk.snapenhance.core.features.impl.ui.* import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.manager.Manager @@ -111,6 +112,7 @@ class FeatureManager( Stories::class, DisableComposerModules::class, FideliusIndicator::class, + PreventMessageListAutoScroll::class, ) initializeFeatures() From 0984644adcaddcc0a4efc18e7949d9b8f4dbe4d9 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 12:57:07 +0100 Subject: [PATCH 266/274] fix(core/media_downloader): public stories username --- .../core/features/impl/downloader/MediaDownloader.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt index dde9931e2..33eb95758 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -372,11 +372,16 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //public stories if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && (forceDownload || canAutoDownload("public_stories"))) { - val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").sanitizeForPath() + val username = ( + paramMap["USERNAME"]?.toString()?.substringAfter("value=") + ?.substringBefore(")")?.substringBefore(",") + ?: paramMap["USER_DISPLAY_NAME"]?.toString() + ?: "unknown" + ).sanitizeForPath() downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaAuthor = userDisplayName, + mediaAuthor = username, downloadSource = MediaDownloadSource.PUBLIC_STORY, creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), ), mediaInfoMap) From a7275c2a0b7a613ffc1b51966db00fbfae52eb36 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 14:56:24 +0100 Subject: [PATCH 267/274] feat: mark stories as seen locally --- common/src/main/assets/lang/en_US.json | 10 ++++------ .../common/config/impl/UserInterfaceTweaks.kt | 2 +- .../core/database/DatabaseAccess.kt | 9 +++++++++ .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 20 ++++++++++++++++--- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index fcd293e3b..25e175f43 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -307,10 +307,6 @@ "name": "Vertical Story Viewer", "description": "Enables the vertical story viewer for all stories" }, - "friend_feed_menu_buttons": { - "name": "Friend Feed Menu Buttons", - "description": "Select which buttons to show in the Friend Feed Menu Bar" - }, "friend_feed_menu_position": { "name": "Friend Feed Position Index", "description": "The position of the Friend Feed Menu component" @@ -682,7 +678,8 @@ "auto_download": "\u2B07\uFE0F Auto Download", "auto_save": "\uD83D\uDCAC Auto Save Messages", "stealth": "\uD83D\uDC7B Stealth Mode", - "mark_as_seen": "\uD83D\uDC40 Mark Snaps as seen", + "mark_snaps_as_seen": "\uD83D\uDC40 Mark Snaps as seen", + "mark_stories_as_seen": "\uD83D\uDC40 Mark Stories as seen", "conversation_info": "\uD83D\uDC64 Conversation Info", "e2e_encryption": "\uD83D\uDD12 Use E2E Encryption" }, @@ -784,7 +781,8 @@ }, "friend_menu_option": { - "mark_as_seen": "Mark Snaps as seen", + "mark_snaps_as_seen": "Mark Snaps as seen", + "mark_stories_as_seen": "Mark Stories as seen", "preview": "Preview", "stealth_mode": "Stealth Mode", "auto_download_blacklist": "Auto Download Blacklist", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index ddb80b2f9..c35c06f82 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -19,7 +19,7 @@ class UserInterfaceTweaks : ConfigContainer() { } val friendFeedMenuButtons = multiple( - "friend_feed_menu_buttons","conversation_info", "mark_as_seen", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() + "friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() ).apply { set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt index 6abfc4b13..7fc4c38f5 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -351,4 +351,13 @@ class DatabaseAccess( } } } + + fun markFriendStoriesAsSeen(userId: String) { + openLocalDatabase("main", writeMode = true)?.apply { + performOperation { + execSQL("UPDATE StorySnap SET viewed = 1 WHERE userId = ?", arrayOf(userId)) + } + close() + } + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt index 9c8e83a43..413fca043 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -278,9 +278,10 @@ class FriendFeedInfoMenu : AbstractMenu() { val (conversationId, targetUser) = getCurrentConversationInfo() + val translation = context.translation.getCategory("friend_menu_option") if (friendFeedMenuOptions.contains("conversation_info")) { viewConsumer(Button(view.context).apply { - text = modContext.translation["friend_menu_option.preview"] + text = translation["preview"] applyTheme(view.width, hasRadius = true) setOnClickListener { showPreview( @@ -302,9 +303,9 @@ class FriendFeedInfoMenu : AbstractMenu() { ) } - if (friendFeedMenuOptions.contains("mark_as_seen")) { + if (friendFeedMenuOptions.contains("mark_snaps_as_seen")) { viewConsumer(Button(view.context).apply { - text = modContext.translation["friend_menu_option.mark_as_seen"] + text = translation["mark_snaps_as_seen"] applyTheme(view.width, hasRadius = true) setOnClickListener { this@FriendFeedInfoMenu.context.mainActivity?.triggerRootCloseTouchEvent() @@ -312,5 +313,18 @@ class FriendFeedInfoMenu : AbstractMenu() { } }) } + + if (targetUser != null && friendFeedMenuOptions.contains("mark_stories_as_seen")) { + viewConsumer(Button(view.context).apply { + text = translation["mark_stories_as_seen"] + applyTheme(view.width, hasRadius = true) + setOnClickListener { + this@FriendFeedInfoMenu.context.apply { + mainActivity?.triggerRootCloseTouchEvent() + database.markFriendStoriesAsSeen(targetUser) + } + } + }) + } } } \ No newline at end of file From dd8c51fe55220ad86d37b560b90bd66768bc91ab Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 18:33:30 +0100 Subject: [PATCH 268/274] fix: auto save --- common/src/main/assets/lang/en_US.json | 4 ++++ .../snapenhance/core/features/impl/messaging/AutoSave.kt | 2 +- .../me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 25e175f43..01f5b91e0 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -303,6 +303,10 @@ "name": "Hide Settings Gear", "description": "Hides the SnapEnhance Settings Gear in friend feed" }, + "friend_feed_menu_buttons": { + "name": "Friend Feed Menu Buttons", + "description": "Select which buttons to show in the Friend Feed Menu" + }, "vertical_story_viewer": { "name": "Vertical Story Viewer", "description": "Enables the vertical story viewer for all stories" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt index 36bb1a975..aa0ca087f 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -15,7 +15,7 @@ import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID import java.util.concurrent.Executors -class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.INIT_SYNC) { +class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() private val messageLogger by lazy { context.feature(MessageLogger::class) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index e78c3301b..9acafcf1d 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -65,6 +65,7 @@ class FeatureManager( register( EndToEndEncryption::class, ScopeSync::class, + PreventMessageListAutoScroll::class, Messaging::class, MediaDownloader::class, StealthMode::class, @@ -112,7 +113,6 @@ class FeatureManager( Stories::class, DisableComposerModules::class, FideliusIndicator::class, - PreventMessageListAutoScroll::class, ) initializeFeatures() From 80e37306a4a52ef79aedf6c2a1acaf0601a1fc26 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 19:04:34 +0100 Subject: [PATCH 269/274] feat: half swipe notifier duration range --- common/src/main/assets/lang/en_US.json | 12 +++++++++++- .../common/config/impl/MessagingTweaks.kt | 12 +++++++++++- .../core/features/impl/spying/HalfSwipeNotifier.kt | 13 +++++++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 01f5b91e0..d7f64d2d8 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -363,7 +363,17 @@ }, "half_swipe_notifier": { "name": "Half Swipe Notifier", - "description": "Notifies you when someone half swipes into a conversation" + "description": "Notifies you when someone half swipes into a conversation", + "properties": { + "min_duration": { + "name": "Minimum Duration", + "description": "The minimum duration of the half swipe (in seconds)" + }, + "max_duration": { + "name": "Maximum Duration", + "description": "The maximum duration of the half swipe (in seconds)" + } + } }, "message_preview_length": { "name": "Message Preview Length", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt index 797ffe750..8763b9c4d 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -2,9 +2,19 @@ package me.rhunk.snapenhance.common.config.impl import me.rhunk.snapenhance.common.config.ConfigContainer import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.config.PropertyValue import me.rhunk.snapenhance.common.data.NotificationType class MessagingTweaks : ConfigContainer() { + inner class HalfSwipeNotifierConfig : ConfigContainer(hasGlobalState = true) { + val minDuration: PropertyValue = integer("min_duration", defaultValue = 0) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && maxDuration.get() >= it.toInt() } + } + val maxDuration: PropertyValue = integer("max_duration", defaultValue = 20) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && minDuration.get() <= it.toInt() } + } + } + val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() } val anonymousStoryViewing = boolean("anonymous_story_viewing") val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() } @@ -13,7 +23,7 @@ class MessagingTweaks : ConfigContainer() { val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") val disableReplayInFF = boolean("disable_replay_in_ff") - val halfSwipeNotifier = boolean("half_swipe_notifier") { requireRestart() } + val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()} val messagePreviewLength = integer("message_preview_length", defaultValue = 20) val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() } val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt index 90f17c127..e5bbe9894 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/HalfSwipeNotifier.kt @@ -36,7 +36,7 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoa override fun init() { - if (!context.config.messaging.halfSwipeNotifier.get()) return + if (context.config.messaging.halfSwipeNotifier.globalState != true) return lateinit var presenceService: Any findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) { @@ -84,7 +84,12 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoa private fun endPeeking(conversationId: String, userId: String) { startPeekingTimestamps[conversationId + userId]?.let { startPeekingTimestamp -> - val peekingDuration = (System.currentTimeMillis() - startPeekingTimestamp).milliseconds.inWholeSeconds.toString() + val peekingDuration = (System.currentTimeMillis() - startPeekingTimestamp).milliseconds.inWholeSeconds + val minDuration = context.config.messaging.halfSwipeNotifier.minDuration.get().toLong() + val maxDuration = context.config.messaging.halfSwipeNotifier.maxDuration.get().toLong() + + if (minDuration > peekingDuration || maxDuration < peekingDuration) return + val groupName = context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName val friendInfo = context.database.getFriendInfo(userId) ?: return @@ -94,12 +99,12 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier", loadParams = FeatureLoa translation.format("notification_content_group", "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), "group" to groupName, - "duration" to peekingDuration + "duration" to peekingDuration.toString() ) } else { translation.format("notification_content_dm", "friend" to (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), - "duration" to peekingDuration + "duration" to peekingDuration.toString() ) }) .setContentIntent( From c9526931e8cc5cc8202cecb701dd6570931713a2 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 3 Dec 2023 23:48:34 +0100 Subject: [PATCH 270/274] feat(scripting): auto reload - refactor ScriptsSection --- .../scripting/AutoReloadHandler.kt | 41 ++++++ .../scripting/RemoteScriptManager.kt | 28 +++- .../scripting/impl/ui/InterfaceManager.kt | 13 +- .../scripting/impl/ui/components/NodeType.kt | 3 +- .../impl/ui/components/impl/ActionNode.kt | 15 +++ .../sections/scripting/ScriptInterface.kt | 22 ++++ .../sections/scripting/ScriptsSection.kt | 124 ++++++++++++++---- .../bridge/scripting/AutoReloadListener.aidl | 5 + .../bridge/scripting/IScripting.aidl | 3 + common/src/main/assets/lang/en_US.json | 8 +- .../common/config/impl/Scripting.kt | 2 +- .../snapenhance/common/scripting/JSModule.kt | 2 +- .../common/scripting/ktx/RhinoKtx.kt | 2 +- .../me/rhunk/snapenhance/core/ModContext.kt | 2 +- .../core/scripting/CoreScriptRuntime.kt | 13 +- 15 files changed, 243 insertions(+), 40 deletions(-) create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt create mode 100644 common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt new file mode 100644 index 000000000..fc2241929 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.scripting + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class AutoReloadHandler( + private val coroutineScope: CoroutineScope, + private val onReload: (DocumentFile) -> Unit, +) { + private val files = mutableListOf() + private val lastModifiedMap = mutableMapOf() + + fun addFile(file: DocumentFile) { + files.add(file) + lastModifiedMap[file.uri] = file.lastModified() + } + + fun start() { + coroutineScope.launch(Dispatchers.IO) { + while (true) { + files.forEach { file -> + val lastModified = lastModifiedMap[file.uri] ?: return@forEach + runCatching { + val newLastModified = file.lastModified() + if (newLastModified > lastModified) { + lastModifiedMap[file.uri] = newLastModified + onReload(file) + } + }.onFailure { + it.printStackTrace() + } + } + delay(1000) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt index 153bb0454..ac3a027d6 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -3,6 +3,7 @@ package me.rhunk.snapenhance.scripting import android.net.Uri import androidx.documentfile.provider.DocumentFile import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener import me.rhunk.snapenhance.bridge.scripting.IPCListener import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.common.scripting.ScriptRuntime @@ -15,12 +16,30 @@ import me.rhunk.snapenhance.scripting.impl.RemoteScriptConfig import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager import java.io.File import java.io.InputStream +import kotlin.system.exitProcess class RemoteScriptManager( val context: RemoteSideContext, ) : IScripting.Stub() { val runtime = ScriptRuntime(context.androidContext, context.log) + private var autoReloadListener: AutoReloadListener? = null + private val autoReloadHandler by lazy { + AutoReloadHandler(context.coroutineScope) { + runCatching { + autoReloadListener?.restartApp() + if (context.config.root.scripting.autoReload.getNullable() == "all") { + exitProcess(1) + } + }.onFailure { + context.log.warn("Failed to restart app") + autoReloadListener = null + } + }.apply { + start() + } + } + private val cachedModuleInfo = mutableMapOf() private val ipcListeners = IPCListeners() @@ -57,6 +76,9 @@ class RemoteScriptManager( fun loadScript(name: String) { val content = getScriptContent(name) ?: return + if (context.config.root.scripting.autoReload.getNullable() != null) { + autoReloadHandler.addFile(getScriptsFolder()?.findFile(name) ?: return) + } runtime.load(name, content) } @@ -73,7 +95,7 @@ class RemoteScriptManager( } } - private fun getScriptsFolder() = runCatching { + fun getScriptsFolder() = runCatching { DocumentFile.fromTreeUri(context.androidContext, Uri.parse(context.config.root.scripting.moduleFolder.get())) }.onFailure { context.log.warn("Failed to get scripts folder") @@ -141,4 +163,8 @@ class RemoteScriptManager( context.log.error("Failed to perform config transaction", it) }.getOrDefault("") } + + override fun registerAutoReloadListener(listener: AutoReloadListener?) { + autoReloadListener = listener + } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt index 5c2d1eb37..e4c2e4fa5 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/InterfaceManager.kt @@ -4,6 +4,8 @@ import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.scripting.impl.ui.components.Node import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType import me.rhunk.snapenhance.scripting.impl.ui.components.impl.RowColumnNode import org.mozilla.javascript.Context import org.mozilla.javascript.Function @@ -14,13 +16,20 @@ class InterfaceBuilder { val nodes = mutableListOf() var onDisposeCallback: (() -> Unit)? = null - private fun createNode(type: NodeType, block: Node.() -> Unit): Node { return Node(type).apply(block).also { nodes.add(it) } } fun onDispose(block: () -> Unit) { - onDisposeCallback = block + nodes.add(ActionNode(ActionType.DISPOSE, callback = block)) + } + + fun onLaunched(block: () -> Unit) { + onLaunched(Unit, block) + } + + fun onLaunched(key: Any, block: () -> Unit) { + nodes.add(ActionNode(ActionType.LAUNCHED, key, block)) } fun row(block: (InterfaceBuilder) -> Unit) = RowColumnNode(NodeType.ROW).apply { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt index 4319ccb1c..d3dde3723 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/NodeType.kt @@ -6,5 +6,6 @@ enum class NodeType { SWITCH, BUTTON, SLIDER, - LIST + LIST, + ACTION } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt new file mode 100644 index 000000000..dd2cc9ba0 --- /dev/null +++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/ui/components/impl/ActionNode.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.scripting.impl.ui.components.impl + +import me.rhunk.snapenhance.scripting.impl.ui.components.Node +import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType + +enum class ActionType { + LAUNCHED, + DISPOSE +} + +class ActionNode( + val actionType: ActionType, + val key: Any = Unit, + val callback: () -> Unit +): Node(NodeType.ACTION) \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt index da2713787..a11d5a335 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptInterface.kt @@ -16,6 +16,8 @@ import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.scripting.impl.ui.InterfaceBuilder import me.rhunk.snapenhance.scripting.impl.ui.components.Node import me.rhunk.snapenhance.scripting.impl.ui.components.NodeType +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionNode +import me.rhunk.snapenhance.scripting.impl.ui.components.impl.ActionType import kotlin.math.abs @@ -68,6 +70,26 @@ private fun DrawNode(node: Node) { } when (node.type) { + NodeType.ACTION -> { + when ((node as ActionNode).actionType) { + ActionType.LAUNCHED -> { + LaunchedEffect(node.key) { + runCallbackSafe { + node.callback() + } + } + } + ActionType.DISPOSE -> { + DisposableEffect(Unit) { + onDispose { + runCallbackSafe { + node.callback() + } + } + } + } + } + } NodeType.COLUMN -> { Column( verticalArrangement = arrangement as? Arrangement.Vertical ?: spacing?.let { Arrangement.spacedBy(it.dp) } ?: Arrangement.Top, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt index 3d15d44e6..d8ae0602c 100644 --- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt +++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/scripting/ScriptsSection.kt @@ -1,9 +1,12 @@ package me.rhunk.snapenhance.ui.manager.sections.scripting +import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* @@ -11,16 +14,25 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.documentfile.provider.DocumentFile import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager import me.rhunk.snapenhance.ui.manager.Section +import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.chooseFolder import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState class ScriptsSection : Section() { + private lateinit var activityLauncherHelper: ActivityLauncherHelper + + override fun init() { + activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + } + @Composable fun ModuleItem(script: ModuleInfo) { var enabled by remember { @@ -50,22 +62,11 @@ class ScriptsSection : Section() { .weight(1f) .padding(end = 8.dp) ) { - Text( - text = script.name, - fontSize = 20.sp, - ) - Text( - text = script.description ?: "No description", - fontSize = 14.sp, - ) + Text(text = script.name, fontSize = 20.sp,) + Text(text = script.description ?: "No description", fontSize = 14.sp,) } - IconButton(onClick = { - openSettings = !openSettings - }) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - ) + IconButton(onClick = { openSettings = !openSettings }) { + Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) } Switch( checked = enabled, @@ -85,43 +86,94 @@ class ScriptsSection : Section() { } } + @Composable + override fun FloatingActionButton() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + ExtendedFloatingActionButton( + onClick = { + + }, + icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, + text = { + Text(text = "Import from URL") + }, + ) + ExtendedFloatingActionButton( + onClick = { + context.scriptManager.getScriptsFolder()?.let { + context.androidContext.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = it.uri + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } + }, + icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, + text = { + Text(text = "Open Scripts Folder") + }, + ) + } + } + @Composable fun ScriptSettings(script: ModuleInfo) { + var settingsError by remember { + mutableStateOf(null as Throwable?) + } + val settingsInterface = remember { val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null - (module.extras["im"] as? InterfaceManager)?.buildInterface("settings") - } ?: run { + runCatching { + (module.extras["im"] as? InterfaceManager)?.buildInterface("settings") + }.onFailure { + settingsError = it + }.getOrNull() + } + + if (settingsInterface == null) { Text( - text = "This module does not have any settings", + text = settingsError?.message ?: "This module does not have any settings", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp) ) - return + } else { + ScriptInterface(interfaceBuilder = settingsInterface) } - - ScriptInterface(interfaceBuilder = settingsInterface) } @Composable override fun Content() { - var scriptModules by remember { - mutableStateOf(context.modDatabase.getScripts()) - } + var scriptModules by remember { mutableStateOf(listOf()) } + var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } val coroutineScope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { - refreshing = true + fun syncScripts() { runCatching { + scriptingFolder = context.scriptManager.getScriptsFolder() context.scriptManager.sync() scriptModules = context.modDatabase.getScripts() }.onFailure { context.log.error("Failed to sync scripts", it) } + } + + LaunchedEffect(Unit) { + syncScripts() + } + + val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { + refreshing = true + syncScripts() coroutineScope.launch { delay(300) refreshing = false @@ -138,7 +190,25 @@ class ScriptsSection : Section() { horizontalAlignment = Alignment.CenterHorizontally ) { item { - if (scriptModules.isEmpty()) { + if (scriptingFolder == null) { + Text( + text = "No scripts folder selected", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + activityLauncherHelper.chooseFolder { + context.config.root.scripting.moduleFolder.set(it) + context.config.writeConfig() + coroutineScope.launch { + syncScripts() + } + } + }) { + Text(text = "Select folder") + } + } else if (scriptModules.isEmpty()) { Text( text = "No scripts found", style = MaterialTheme.typography.bodySmall, diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl new file mode 100644 index 000000000..09cdb0920 --- /dev/null +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/AutoReloadListener.aidl @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.bridge.scripting; + +interface AutoReloadListener { + oneway void restartApp(); +} \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl index 2df5a5258..b53d162e8 100644 --- a/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl +++ b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.bridge.scripting; import me.rhunk.snapenhance.bridge.scripting.IPCListener; +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener; interface IScripting { List getEnabledScripts(); @@ -12,4 +13,6 @@ interface IScripting { void sendIPCMessage(String channel, String eventName, in String[] args); @nullable String configTransaction(String module, String action, @nullable String key, @nullable String value, boolean save); + + void registerAutoReloadListener(in AutoReloadListener listener); } \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index d7f64d2d8..7a64def6e 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -663,8 +663,8 @@ "name": "Module Folder", "description": "The folder where the scripts are located" }, - "hot_reload": { - "name": "Hot Reload", + "auto_reload": { + "name": "Auto Reload", "description": "Automatically reloads scripts when they change" }, "disable_log_anonymization": { @@ -790,6 +790,10 @@ "hide_friend": "Hide Friend", "hide_conversation": "Hide Conversation", "clear_conversation": "Clear Conversation from Friend Feed" + }, + "auto_reload": { + "snapchat_only": "Snapchat Only", + "all": "All (Snapchat + SnapEnhance)" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt index a52668373..83b3ec169 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Scripting.kt @@ -6,6 +6,6 @@ import me.rhunk.snapenhance.common.config.ConfigFlag class Scripting : ConfigContainer() { val developerMode = boolean("developer_mode", false) { requireRestart() } val moduleFolder = string("module_folder", "modules") { addFlags(ConfigFlag.FOLDER); requireRestart() } - val hotReload = boolean("hot_reload", false) + val autoReload = unique("auto_reload", "snapchat_only", "all") val disableLogAnonymization = boolean("disable_log_anonymization", false) { requireRestart() } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt index 1ab5057b3..7cd02d8ab 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt @@ -81,7 +81,7 @@ class JSModule( putFunction(method.name) { args -> clazz.declaredMethods.find { it.name == method.name && it.parameterTypes.zip(args ?: emptyArray()).all { (type, arg) -> - type.isAssignableFrom(arg.javaClass) + type.isAssignableFrom(arg?.javaClass ?: return@all false) } }?.invoke(null, *args ?: emptyArray()) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt index b59e4b676..39b469e28 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ktx/RhinoKtx.kt @@ -23,7 +23,7 @@ fun Scriptable.function(name: String): Function? { return this.get(name, this) as? Function } -fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array?) -> Any?) { +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array?) -> Any?) { this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { override fun call( cx: Context?, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt index b54ceb753..5931479d9 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -62,7 +62,7 @@ class ModContext( val event = EventBus(this) val eventDispatcher = EventDispatcher(this) val native = NativeLib() - val scriptRuntime by lazy { CoreScriptRuntime(androidContext, log) } + val scriptRuntime by lazy { CoreScriptRuntime(this, log) } val messagingBridge = CoreMessagingBridge(this) val isDeveloper by lazy { config.scripting.developerMode.get() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt index fc240315b..32362ed6a 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/CoreScriptRuntime.kt @@ -1,17 +1,18 @@ package me.rhunk.snapenhance.core.scripting -import android.content.Context +import me.rhunk.snapenhance.bridge.scripting.AutoReloadListener import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.scripting.impl.CoreIPC import me.rhunk.snapenhance.core.scripting.impl.CoreScriptConfig import me.rhunk.snapenhance.core.scripting.impl.ScriptHooker class CoreScriptRuntime( - androidContext: Context, + private val modContext: ModContext, logger: AbstractLogger, -): ScriptRuntime(androidContext, logger) { +): ScriptRuntime(modContext.androidContext, logger) { private val scriptHookers = mutableListOf() fun connect(scriptingInterface: IScripting) { @@ -31,6 +32,12 @@ class CoreScriptRuntime( logger.error("Failed to load script $path", it) } } + + registerAutoReloadListener(object : AutoReloadListener.Stub() { + override fun restartApp() { + modContext.softRestartApp() + } + }) } } } \ No newline at end of file From d7620409375dd089a3099475f6bf9e455daa6cca Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 4 Dec 2023 22:26:13 +0100 Subject: [PATCH 271/274] feat(core/ui_tweaks): hide unread chat hint --- common/src/main/assets/lang/en_US.json | 3 ++- .../snapenhance/common/config/impl/UserInterfaceTweaks.kt | 3 ++- .../kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt | 4 ++-- .../me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 7a64def6e..db67ce3af 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -752,7 +752,8 @@ "hide_chat_call_buttons": "Remove Chat Call Buttons", "hide_live_location_share_button": "Remove Live Location Share Button", "hide_stickers_button": "Remove Stickers Button", - "hide_voice_record_button": "Remove Voice Record Button" + "hide_voice_record_button": "Remove Voice Record Button", + "hide_unread_chat_hint": "Remove Unread Chat Hint" }, "hide_story_sections": { "hide_friend_suggestions": "Hide friend suggestions", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index c35c06f82..19a8e9f7e 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -41,7 +41,8 @@ class UserInterfaceTweaks : ConfigContainer() { "hide_stickers_button", "hide_live_location_share_button", "hide_chat_call_buttons", - "hide_profile_call_buttons" + "hide_profile_call_buttons", + "hide_unread_chat_hint", ) { requireRestart() } val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt index fc32fd678..d4b8d98f8 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt @@ -9,8 +9,8 @@ import me.rhunk.snapenhance.core.features.FeatureLoadParams import java.nio.ByteBuffer import kotlin.coroutines.suspendCoroutine -class Stories : Feature("Stories", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { +class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { val disablePublicStories by context.config.global.disablePublicStories context.event.subscribe(NetworkApiRequestEvent::class) { event -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt index 14aa3cdb0..b9ad224ff 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt @@ -50,6 +50,7 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE val callButton2 = getId("friend_action_button4", "id") val chatNoteRecordButton = getId("chat_note_record_button", "id") + val unreadHintButton = getId("unread_hint_button", "id") View::class.java.hook("setVisibility", HookStage.BEFORE) { methodParam -> val viewId = (methodParam.thisObject() as View).id @@ -145,6 +146,9 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE } } } + if (viewId == unreadHintButton && hiddenElements.contains("hide_unread_chat_hint")) { + event.canceled = true + } } } } \ No newline at end of file From ce2fe64c37c66313273a6776d82d0fbc0f5fd391 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 4 Dec 2023 23:17:35 +0100 Subject: [PATCH 272/274] fix(common): config deserializer error handler --- .../rhunk/snapenhance/common/config/ConfigContainer.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt index 769649ae2..eeef5759f 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigContainer.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.common.config import android.content.Context import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.logger.AbstractLogger import kotlin.reflect.KProperty typealias ConfigParamsBuilder = ConfigParams.() -> Unit @@ -78,9 +79,12 @@ open class ConfigContainer( fun fromJson(json: JsonObject) { properties.forEach { (key, _) -> - val jsonElement = json.get(key.name) ?: return@forEach - //TODO: check incoming values - properties[key]?.setAny(key.dataType.deserializeAny(jsonElement)) + runCatching { + val jsonElement = json.get(key.name) ?: return@forEach + properties[key]?.setAny(key.dataType.deserializeAny(jsonElement)) + }.onFailure { + AbstractLogger.directError("Failed to deserialize property ${key.name}", it) + } } } From 35016b589f7c9dd957aae18d94a8eb98bae76f12 Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 4 Dec 2023 23:18:02 +0100 Subject: [PATCH 273/274] feat(core): edit text override --- common/src/main/assets/lang/en_US.json | 8 +++++ .../common/config/impl/UserInterfaceTweaks.kt | 3 ++ .../core/features/impl/ui/EditTextOverride.kt | 36 +++++++++++++++++++ .../core/manager/impl/FeatureManager.kt | 1 + 4 files changed, 48 insertions(+) create mode 100644 core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index db67ce3af..12f0dd309 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -322,6 +322,10 @@ "fidelius_indicator": { "name": "Fidelius Indicator", "description": "Adds a green circle next to messages that have been sent only to you" + }, + "edit_text_override": { + "name": "Edit Text Override", + "description": "Overrides text field behavior" } } }, @@ -795,6 +799,10 @@ "auto_reload": { "snapchat_only": "Snapchat Only", "all": "All (Snapchat + SnapEnhance)" + }, + "edit_text_override": { + "multi_line_chat_input": "Multi Line Chat Input", + "bypass_text_input_limit": "Bypass Text Input Limit" } } }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt index 19a8e9f7e..4436ea957 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -50,4 +50,7 @@ class UserInterfaceTweaks : ConfigContainer() { val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } val fideliusIndicator = boolean("fidelius_indicator") { requireRestart() } + val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") { + requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR) + } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt new file mode 100644 index 000000000..71dab11ce --- /dev/null +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/EditTextOverride.kt @@ -0,0 +1,36 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.text.InputFilter +import android.text.InputType +import android.widget.EditText +import android.widget.TextView +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor + +class EditTextOverride : Feature("Edit Text Override", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + val editTextOverride by context.config.userInterface.editTextOverride + if (editTextOverride.isEmpty()) return + + if (editTextOverride.contains("bypass_text_input_limit")) { + TextView::class.java.getMethod("setFilters", Array::class.java) + .hook(HookStage.BEFORE) { param -> + param.setArg(0, param.arg>(0).filter { + it !is InputFilter.LengthFilter + }.toTypedArray()) + } + } + + if (editTextOverride.contains("multi_line_chat_input")) { + findClass("com.snap.messaging.chat.features.input.InputBarEditText").apply { + hookConstructor(HookStage.AFTER) { param -> + val editText = param.thisObject() + editText.inputType = editText.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt index 9acafcf1d..ccafe8f9b 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/FeatureManager.kt @@ -113,6 +113,7 @@ class FeatureManager( Stories::class, DisableComposerModules::class, FideliusIndicator::class, + EditTextOverride::class, ) initializeFeatures() From 440c35e4230efd610ba19d9a38e098f75b799b7d Mon Sep 17 00:00:00 2001 From: rhunk <101876869+rhunk@users.noreply.github.com> Date: Tue, 5 Dec 2023 18:43:41 +0100 Subject: [PATCH 274/274] feat(scripting): module.onBeforeApplicationLoad --- .../me/rhunk/snapenhance/common/scripting/JSModule.kt | 6 +++++- .../main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt index 7cd02d8ab..161da228a 100644 --- a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt +++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt @@ -135,7 +135,11 @@ class JSModule( obj.get(key, obj) as? ScriptableObject ?: return@contextScope }.get(split.last(), moduleObject) as? Function ?: return@contextScope - function.call(this, moduleObject, moduleObject, args) + runCatching { + function.call(this, moduleObject, moduleObject, args) + }.onFailure { + scriptRuntime.logger.error("Error while calling function $name", it) + } } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt index b845db204..b1b107187 100644 --- a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt +++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -142,6 +142,7 @@ class SnapEnhance { bridgeClient.registerMessagingBridge(messagingBridge) features.init() scriptRuntime.connect(bridgeClient.getScriptingInterface()) + scriptRuntime.eachModule { callFunction("module.onBeforeApplicationLoad", androidContext) } syncRemote() } }