commit 13d6ae75542e0e29c41e8ba0c35d913f5ecde95c parent 00a33b9ce63d8017caf4d742f5df42faf2e225bc Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Wed, 11 Oct 2023 01:25:27 +0200 refactor: common submodule - organize import - remove unused xml Diffstat:
390 files changed, 11059 insertions(+), 11120 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { } implementation(project(":core")) + implementation(project(":common")) implementation(libs.androidx.documentfile) implementation(libs.gson) implementation(libs.ffmpeg.kit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -28,7 +28,7 @@ android:value="93" /> <meta-data android:name="xposedscope" - android:resource="@array/xposed_scope" /> + android:value="com.snapchat.android" /> <service android:name=".bridge.BridgeService" diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -3,9 +3,9 @@ package me.rhunk.snapenhance import android.content.SharedPreferences import android.util.Log import com.google.gson.GsonBuilder -import me.rhunk.snapenhance.core.logger.AbstractLogger -import me.rhunk.snapenhance.core.logger.LogChannel -import me.rhunk.snapenhance.core.logger.LogLevel +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel import java.io.File import java.io.OutputStream import java.io.RandomAccessFile diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -17,10 +17,10 @@ import coil.memory.MemoryCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import me.rhunk.snapenhance.bridge.BridgeService -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.common.BuildConfig +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.common.config.ModConfig import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.messaging.ModDatabase diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -5,16 +5,16 @@ import android.content.Intent import android.os.IBinder import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder -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.logger.LogLevel -import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo -import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo -import me.rhunk.snapenhance.core.messaging.SocialScope -import me.rhunk.snapenhance.core.util.SerializableDataObject +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import me.rhunk.snapenhance.common.bridge.types.FileActionType +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.common.util.SerializableDataObject import me.rhunk.snapenhance.download.DownloadProcessor import kotlin.system.measureTimeMillis diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/ForceStartActivity.kt @@ -3,8 +3,8 @@ package me.rhunk.snapenhance.bridge import android.app.Activity import android.content.Intent import android.os.Bundle -import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.SharedContextHolder +import me.rhunk.snapenhance.common.Constants class ForceStartActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadObject.kt @@ -1,8 +1,8 @@ package me.rhunk.snapenhance.download import kotlinx.coroutines.Job -import me.rhunk.snapenhance.core.download.data.DownloadMetadata -import me.rhunk.snapenhance.core.download.data.DownloadStage +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadStage data class DownloadObject( var downloadId: Int = 0, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -13,14 +13,14 @@ import kotlinx.coroutines.job import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.bridge.DownloadCallback -import me.rhunk.snapenhance.core.download.DownloadManagerClient -import me.rhunk.snapenhance.core.download.data.* -import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver import java.io.File import java.io.InputStream import java.net.HttpURLConnection @@ -296,8 +296,8 @@ class DownloadProcessor ( fun onReceive(intent: Intent) { 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) + 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) remoteSideContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage -> translation[if (downloadStage.isFinalStage) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -3,12 +3,12 @@ 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.DownloadStage -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 me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadStage +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import java.util.concurrent.Executors class DownloadTaskManager { @@ -162,7 +162,8 @@ class DownloadTaskManager { metadata = DownloadMetadata( outputPath = cursor.getStringOrNull("outputPath")!!, mediaIdentifier = cursor.getStringOrNull("hash"), - downloadSource = cursor.getStringOrNull("downloadSource") ?: MediaDownloadSource.NONE.key, + downloadSource = cursor.getStringOrNull("downloadSource") + ?: MediaDownloadSource.NONE.key, mediaAuthor = cursor.getStringOrNull("mediaAuthor"), iconUrl = cursor.getStringOrNull("iconUrl") ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -5,9 +5,8 @@ import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.Level import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.LogManager -import me.rhunk.snapenhance.core.Logger -import me.rhunk.snapenhance.core.config.impl.DownloaderConfig -import me.rhunk.snapenhance.core.logger.LogLevel +import me.rhunk.snapenhance.common.config.impl.DownloaderConfig +import me.rhunk.snapenhance.common.logger.LogLevel import java.io.File import java.util.concurrent.Executors @@ -71,7 +70,7 @@ class FFMpegProcessor( } } - Logger.directDebug("arguments: $stringBuilder", "FFMpegProcessor") + logManager.debug("arguments: $stringBuilder", "FFMpegProcessor") FFmpegKit.executeAsync(stringBuilder.toString(), { session -> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -2,16 +2,16 @@ package me.rhunk.snapenhance.messaging import android.database.sqlite.SQLiteDatabase 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.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 me.rhunk.snapenhance.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import java.util.concurrent.Executors @@ -75,11 +75,13 @@ class ModDatabase( return database.rawQuery("SELECT * FROM groups", null).use { cursor -> val groups = mutableListOf<MessagingGroupInfo>() while (cursor.moveToNext()) { - groups.add(MessagingGroupInfo( + groups.add( + MessagingGroupInfo( conversationId = cursor.getStringOrNull("conversationId")!!, name = cursor.getStringOrNull("name")!!, participantsCount = cursor.getInteger("participantsCount") - )) + ) + ) } groups } @@ -90,13 +92,15 @@ class ModDatabase( val friends = mutableListOf<MessagingFriendInfo>() while (cursor.moveToNext()) { runCatching { - friends.add(MessagingFriendInfo( + friends.add( + MessagingFriendInfo( userId = cursor.getStringOrNull("userId")!!, displayName = cursor.getStringOrNull("displayName"), mutableUsername = cursor.getStringOrNull("mutableUsername")!!, bitmojiId = cursor.getStringOrNull("bitmojiId"), selfieId = cursor.getStringOrNull("selfieId") - )) + ) + ) }.onFailure { context.log.error("Failed to parse friend", it) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -14,7 +14,7 @@ import me.rhunk.snapenhance.R import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.bridge.ForceStartActivity -import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.util.ImageRequestHelper class StreaksReminder( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -5,11 +5,12 @@ import androidx.documentfile.provider.DocumentFile import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.bridge.scripting.IPCListener import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.scripting.impl.IPCListeners +import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC import me.rhunk.snapenhance.scripting.impl.ui.InterfaceBuilder import me.rhunk.snapenhance.scripting.impl.ui.InterfaceManager -import me.rhunk.snapenhance.scripting.impl.RemoteManagerIPC -import me.rhunk.snapenhance.scripting.type.ModuleInfo import java.io.InputStream class RemoteScriptManager( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteManagerIPC.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/impl/RemoteManagerIPC.kt @@ -2,10 +2,10 @@ package me.rhunk.snapenhance.scripting.impl import android.os.DeadObjectException import me.rhunk.snapenhance.bridge.scripting.IPCListener -import me.rhunk.snapenhance.core.logger.AbstractLogger -import me.rhunk.snapenhance.scripting.IPCInterface -import me.rhunk.snapenhance.scripting.Listener -import me.rhunk.snapenhance.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.IPCInterface +import me.rhunk.snapenhance.common.scripting.Listener +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import java.util.concurrent.ConcurrentHashMap typealias IPCListeners = ConcurrentHashMap<String, MutableMap<String, MutableSet<IPCListener>>> // channel, eventName -> listeners 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 @@ -1,10 +1,10 @@ package me.rhunk.snapenhance.scripting.impl.ui -import me.rhunk.snapenhance.core.logger.AbstractLogger +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.RowColumnNode -import me.rhunk.snapenhance.scripting.type.ModuleInfo import org.mozilla.javascript.Context import org.mozilla.javascript.Function import org.mozilla.javascript.annotations.JSFunction diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/MapActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/MapActivity.kt @@ -8,7 +8,7 @@ import android.os.Bundle import android.view.MotionEvent import android.widget.Button import android.widget.EditText -import me.rhunk.snapenhance.core.R +import me.rhunk.snapenhance.R import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint 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 @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.ui.manager.data import com.google.gson.JsonParser -import me.rhunk.snapenhance.core.BuildConfig +import me.rhunk.snapenhance.common.BuildConfig import okhttp3.OkHttpClient import okhttp3.Request 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 @@ -28,8 +28,8 @@ import coil.compose.rememberAsyncImagePainter 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.common.data.FileType +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.download.DownloadObject import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.BitmojiImage 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 @@ -37,7 +37,7 @@ import androidx.navigation.navigation import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.rhunk.snapenhance.core.config.* +import me.rhunk.snapenhance.common.config.* import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.* 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 @@ -33,8 +33,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.core.logger.LogChannel -import me.rhunk.snapenhance.core.logger.LogLevel +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel class HomeSubSection( private val context: RemoteSideContext 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 @@ -20,10 +20,9 @@ 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.Constants -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.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.AlertDialogs @@ -83,7 +82,7 @@ class SettingsSection : Section() { private fun launchActionIntent(action: EnumAction) { val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) - intent?.putExtra(ActionManager.ACTION_PARAMETER, action.key) + intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key) context.androidContext.startActivity(intent) } 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 @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -import me.rhunk.snapenhance.core.Logger +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 @@ -54,7 +54,7 @@ private fun DrawNode(node: Node) { runCatching { callback() }.onFailure { - Logger.directError("Error running callback", it) + AbstractLogger.directError("Error running callback", it) } } 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 @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import me.rhunk.snapenhance.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh 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 @@ -24,11 +24,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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.core.messaging.SocialScope -import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper class AddFriendDialog( private val context: RemoteSideContext, @@ -128,7 +128,7 @@ class AddFriendDialog( timeoutJob?.cancel() hasFetchError = false } - SnapWidgetBroadcastReceiverHelper.create(BridgeClient.BRIDGE_SYNC_ACTION) {}.also { + SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also { runCatching { context.androidContext.sendBroadcast(it) }.onFailure { 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 @@ -22,9 +22,9 @@ import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.core.messaging.SocialScope -import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.util.AlertDialogs import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.Dialog 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 @@ -27,10 +27,10 @@ import androidx.navigation.compose.composable import androidx.navigation.navigation import kotlinx.coroutines.launch import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo -import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo -import me.rhunk.snapenhance.core.messaging.SocialScope -import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.AlertDialogs import me.rhunk.snapenhance.ui.util.BitmojiImage 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 @@ -14,14 +14,18 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +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.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.core.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.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/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.core.Logger +import me.rhunk.snapenhance.common.logger.AbstractLogger typealias ActivityLauncherCallback = (resultCode: Int, intent: Intent?) -> Unit @@ -17,7 +17,7 @@ class ActivityLauncherHelper( runCatching { callback?.let { it(if (result) ComponentActivity.RESULT_OK else ComponentActivity.RESULT_CANCELED, null) } }.onFailure { - Logger.directError("Failed to process activity result", it) + AbstractLogger.directError("Failed to process activity result", it) } callback = null } @@ -27,7 +27,7 @@ class ActivityLauncherHelper( runCatching { callback?.let { it(result.resultCode, result.data) } }.onFailure { - Logger.directError("Failed to process activity result", it) + AbstractLogger.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 @@ -22,9 +22,9 @@ 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.core.bridge.wrapper.LocaleWrapper -import me.rhunk.snapenhance.core.config.DataProcessors -import me.rhunk.snapenhance.core.config.PropertyPair +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.config.DataProcessors +import me.rhunk.snapenhance.common.config.PropertyPair class AlertDialogs( diff --git a/core/src/main/res/font/avenir_next_medium.ttf b/app/src/main/res/font/avenir_next_medium.ttf Binary files differ. diff --git a/core/src/main/res/layout/map.xml b/app/src/main/res/layout/map.xml diff --git a/core/src/main/res/layout/precise_location_dialog.xml b/app/src/main/res/layout/precise_location_dialog.xml diff --git a/core/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml diff --git a/core/src/main/res/values/launcher_icon_background.xml b/app/src/main/res/values/launcher_icon_background.xml diff --git a/core/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml diff --git a/core/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml diff --git a/build.gradle.kts b/build.gradle.kts @@ -12,3 +12,10 @@ rootProject.ext.set("appVersionName", versionName) rootProject.ext.set("appVersionCode", versionCode) rootProject.ext.set("applicationId", "me.rhunk.snapenhance") rootProject.ext.set("nativeName", properties["custom_native_name"] ?: java.security.SecureRandom().nextLong(1000000000, 99999999999).toString(16)) + +tasks.register("getVersion") { + doLast { + val versionFile = File("app/build/version.txt") + versionFile.writeText(versionName) + } +} diff --git a/common/.gitignore b/common/.gitignore @@ -0,0 +1 @@ +/build+ \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = rootProject.ext["applicationId"].toString() + ".common" + compileSdk = 34 + + buildFeatures { + aidl = true + buildConfig = true + } + + defaultConfig { + minSdk = 28 + buildConfigField("String", "VERSION_NAME", "\"${rootProject.ext["appVersionName"]}\"") + buildConfigField("int", "VERSION_CODE", "${rootProject.ext["appVersionCode"]}") + buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") + buildConfigField("int", "BUILD_DATE", "${System.currentTimeMillis() / 1000}") + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(libs.coroutines) + implementation(libs.gson) + implementation(libs.okhttp) + implementation(libs.androidx.documentfile) + implementation(libs.rhino) + + implementation(project(":stub")) + implementation(project(":mapper")) +}+ \ No newline at end of file diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/ConfigStateListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/ConfigStateListener.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/DownloadCallback.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/SyncCallback.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/SyncCallback.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/E2eeInterface.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/e2ee/EncryptionResult.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IPCListener.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IPCListener.aidl diff --git a/core/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/scripting/IScripting.aidl diff --git a/core/src/main/assets/lang/ar_SA.json b/common/src/main/assets/lang/ar_SA.json diff --git a/core/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json diff --git a/core/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json diff --git a/core/src/main/assets/lang/fr_FR.json b/common/src/main/assets/lang/fr_FR.json diff --git a/core/src/main/assets/lang/hi_IN.json b/common/src/main/assets/lang/hi_IN.json diff --git a/core/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json diff --git a/core/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json diff --git a/core/src/main/assets/lang/tr_TR.json b/common/src/main/assets/lang/tr_TR.json diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/Constants.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.common + +object Constants { + const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" + + val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) + + 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 @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.common + +object ReceiversConfig { + const val BRIDGE_SYNC_ACTION = BuildConfig.APPLICATION_ID + ".core.bridge.SYNC" + const val DOWNLOAD_REQUEST_EXTRA = "request" + const val DOWNLOAD_METADATA_EXTRA = "metadata" +}+ \ No newline at end of file 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 @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.action + + + +enum class EnumAction( + val key: String, + val exitOnFinish: Boolean = false, + val isCritical: Boolean = false, +) { + CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true), + EXPORT_CHAT_MESSAGES("export_chat_messages"), + OPEN_MAP("open_map"); + + companion object { + const val ACTION_PARAMETER = "se_action" + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/FileLoaderWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/FileLoaderWrapper.kt @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.common.bridge + +import android.content.Context +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType + +open class FileLoaderWrapper( + val fileType: BridgeFileType, + val defaultContent: ByteArray +) { + lateinit var isFileExists: () -> Boolean + lateinit var write: (ByteArray) -> Unit + lateinit var read: () -> ByteArray + lateinit var delete: () -> Unit + + fun loadFromContext(context: Context) { + val file = fileType.resolve(context) + isFileExists = { file.exists() } + read = { + if (!file.exists()) { + file.createNewFile() + file.writeBytes("{}".toByteArray(Charsets.UTF_8)) + } + file.readBytes() + } + write = { file.writeBytes(it) } + delete = { file.delete() } + } + +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/BridgeFileType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/BridgeFileType.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.bridge.types + +import android.content.Context +import java.io.File + + +enum class BridgeFileType(val value: Int, val fileName: String, val displayName: String, private val isDatabase: Boolean = false) { + CONFIG(0, "config.json", "Config"), + MAPPINGS(1, "mappings.json", "Mappings"), + MESSAGE_LOGGER_DATABASE(2, "message_logger.db", "Message Logger",true), + PINNED_CONVERSATIONS(3, "pinned_conversations.txt", "Pinned Conversations"); + + fun resolve(context: Context): File = if (isDatabase) { + context.getDatabasePath(fileName) + } else { + File(context.filesDir, fileName) + } + + companion object { + fun fromValue(value: Int): BridgeFileType? { + return entries.firstOrNull { it.value == value } + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/FileActionType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/FileActionType.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.common.bridge.types + +enum class FileActionType { + CREATE_AND_READ, READ, WRITE, DELETE, EXISTS +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/LocalePair.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/types/LocalePair.kt @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.common.bridge.types + +data class LocalePair( + val locale: String, + val content: String +)+ \ No newline at end of file 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 @@ -0,0 +1,99 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.Context +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import me.rhunk.snapenhance.common.bridge.types.LocalePair +import me.rhunk.snapenhance.common.logger.AbstractLogger +import java.util.Locale + + +class LocaleWrapper { + companion object { + const val DEFAULT_LOCALE = "en_US" + + fun fetchLocales(context: Context, locale: String = DEFAULT_LOCALE): List<LocalePair> { + val locales = mutableListOf<LocalePair>().apply { + add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() })) + } + + if (locale == DEFAULT_LOCALE) return locales + + val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(locale) }?.substring(0, 5) ?: return locales + + context.resources.assets.open("lang/$compatibleLocale.json").use { inputStream -> + locales.add(LocalePair(compatibleLocale, inputStream.bufferedReader().use { it.readText() })) + } + + return locales + } + + fun fetchAvailableLocales(context: Context): List<String> { + return context.resources.assets.list("lang")?.map { it.substring(0, 5) } ?: listOf() + } + } + + var userLocale = DEFAULT_LOCALE + + private val translationMap = linkedMapOf<String, String>() + + lateinit var loadedLocale: Locale + + private fun load(localePair: LocalePair) { + loadedLocale = localePair.locale.let { Locale(it.substring(0, 2), it.substring(3, 5)) } + + val translations = JsonParser.parseString(localePair.content).asJsonObject + if (translations == null || translations.isJsonNull) { + return + } + + fun scanObject(jsonObject: JsonObject, prefix: String = "") { + jsonObject.entrySet().forEach { + if (it.value.isJsonPrimitive) { + val key = "$prefix${it.key}" + translationMap[key] = it.value.asString + } + if (!it.value.isJsonObject) return@forEach + scanObject(it.value.asJsonObject, "$prefix${it.key}.") + } + } + + scanObject(translations) + } + + fun loadFromCallback(callback: (String) -> List<LocalePair>) { + callback(userLocale).forEach { + load(it) + } + } + + fun loadFromContext(context: Context) { + fetchLocales(context, userLocale).forEach { + load(it) + } + } + + fun reloadFromContext(context: Context, locale: String) { + userLocale = locale + translationMap.clear() + loadFromContext(context) + } + + operator fun get(key: String) = translationMap[key] ?: key.also { AbstractLogger.directDebug("Missing translation for $key") } + + fun format(key: String, vararg args: Pair<String, String>): String { + return args.fold(get(key)) { acc, pair -> + acc.replace("{${pair.first}}", pair.second) + } + } + + fun getCategory(key: String): LocaleWrapper { + return LocaleWrapper().apply { + translationMap.putAll( + this@LocaleWrapper.translationMap + .filterKeys { it.startsWith("$key.") } + .mapKeys { it.key.substring(key.length + 1) } + ) + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt @@ -0,0 +1,148 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.Context +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonParser +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.mapper.Mapper +import me.rhunk.snapenhance.mapper.impl.* +import java.util.concurrent.ConcurrentHashMap +import kotlin.system.measureTimeMillis + +class MappingsWrapper : FileLoaderWrapper(BridgeFileType.MAPPINGS, "{}".toByteArray(Charsets.UTF_8)) { + companion object { + private val gson = GsonBuilder().setPrettyPrinting().create() + private val mappers = arrayOf( + BCryptClassMapper::class, + CallbackMapper::class, + DefaultMediaItemMapper::class, + MediaQualityLevelProviderMapper::class, + OperaPageViewControllerMapper::class, + PlatformAnalyticsCreatorMapper::class, + PlusSubscriptionMapper::class, + ScCameraSettingsMapper::class, + StoryBoostStateMapper::class, + FriendsFeedEventDispatcherMapper::class, + CompositeConfigurationProviderMapper::class, + ScoreUpdateMapper::class, + FriendRelationshipChangerMapper::class, + ViewBinderMapper::class, + ) + } + + private lateinit var context: Context + + private val mappings = ConcurrentHashMap<String, Any>() + private var snapBuildNumber: Long = 0 + + fun init(context: Context) { + this.context = context + snapBuildNumber = getSnapchatVersionCode() + + if (isFileExists()) { + runCatching { + loadCached() + }.onFailure { + delete() + } + } + } + + fun getSnapchatPackageInfo() = runCatching { + context.packageManager.getPackageInfo( + Constants.SNAPCHAT_PACKAGE_NAME, + 0 + ) + }.getOrNull() + + fun getSnapchatVersionCode() = getSnapchatPackageInfo()?.longVersionCode ?: -1 + fun getApplicationSourceDir() = getSnapchatPackageInfo()?.applicationInfo?.sourceDir + fun getGeneratedBuildNumber() = snapBuildNumber + + fun isMappingsOutdated(): Boolean { + return snapBuildNumber != getSnapchatVersionCode() || isMappingsLoaded().not() + } + + fun isMappingsLoaded(): Boolean { + return mappings.isNotEmpty() + } + + private fun loadCached() { + if (!isFileExists()) { + throw Exception("Mappings file does not exist") + } + val mappingsObject = JsonParser.parseString(read().toString(Charsets.UTF_8)).asJsonObject.also { + snapBuildNumber = it["snap_build_number"].asLong + } + + mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> -> + if (value.isJsonArray) { + mappings[key] = gson.fromJson(value, ArrayList::class.java) + return@forEach + } + if (value.isJsonObject) { + mappings[key] = gson.fromJson(value, ConcurrentHashMap::class.java) + return@forEach + } + mappings[key] = value.asString + } + } + + fun refresh() { + snapBuildNumber = getSnapchatVersionCode() + val mapper = Mapper(*mappers) + + runCatching { + mapper.loadApk(getApplicationSourceDir() ?: throw Exception("Failed to get APK")) + }.onFailure { + throw Exception("Failed to load APK", it) + } + + measureTimeMillis { + val result = mapper.start().apply { + addProperty("snap_build_number", snapBuildNumber) + } + write(result.toString().toByteArray()) + } + } + + fun getMappedObject(key: String): Any { + if (mappings.containsKey(key)) { + return mappings[key]!! + } + throw Exception("No mapping found for $key") + } + + fun getMappedObjectNullable(key: String): Any? { + return mappings[key] + } + + fun getMappedClass(className: String): Class<*> { + return context.classLoader.loadClass(getMappedObject(className) as String) + } + + fun getMappedClass(key: String, subKey: String): Class<*> { + return context.classLoader.loadClass(getMappedValue(key, subKey)) + } + + fun getMappedValue(key: String): String { + return getMappedObject(key) as String + } + + @Suppress("UNCHECKED_CAST") + fun <T : Any> getMappedList(key: String): List<T> { + return listOf(getMappedObject(key) as List<T>).flatten() + } + + fun getMappedValue(key: String, subKey: String): String { + return getMappedMap(key)[subKey] as String + } + + @Suppress("UNCHECKED_CAST") + fun getMappedMap(key: String): Map<String, *> { + return getMappedObject(key) as Map<String, *> + } +}+ \ No newline at end of file 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 @@ -0,0 +1,79 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +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 +): MessageLoggerInterface.Stub() { + private lateinit var database: SQLiteDatabase + + fun init() { + database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) + SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( + "messages" to listOf( + "id INTEGER PRIMARY KEY", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_data BLOB" + ) + )) + } + + override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { + if (conversationId.any { + runCatching { UUID.fromString(it) }.isFailure + }) return longArrayOf() + + val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ + conversationId.joinToString( + "," + ) { "'$it'" } + }) ORDER BY message_id DESC LIMIT $limit", null) + + val ids = mutableListOf<Long>() + while (cursor.moveToNext()) { + ids.add(cursor.getLong(0)) + } + cursor.close() + return ids.toLongArray() + } + + override fun getMessage(conversationId: String?, id: Long): ByteArray? { + val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, id.toString())) + val message: ByteArray? = if (cursor.moveToFirst()) { + cursor.getBlob(0) + } else { + null + } + cursor.close() + return message + } + + override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { + val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + val state = cursor.moveToFirst() + cursor.close() + if (state) { + return false + } + database.insert("messages", null, ContentValues().apply { + put("conversation_id", conversationId) + put("message_id", messageId) + put("message_data", serializedMessage) + }) + return true + } + + fun clearMessages() { + database.execSQL("DELETE FROM messages") + } + + override fun deleteMessage(conversationId: String, messageId: Long) { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } +}+ \ No newline at end of file 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 @@ -0,0 +1,82 @@ +package me.rhunk.snapenhance.common.config + +import com.google.gson.JsonObject +import kotlin.reflect.KProperty + +typealias ConfigParamsBuilder = ConfigParams.() -> Unit + +open class ConfigContainer( + val hasGlobalState: Boolean = false +) { + var parentContainerKey: PropertyKey<*>? = null + val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() + var globalState: Boolean? = null + + private inline fun <T> registerProperty( + key: String, + type: DataProcessors.PropertyDataProcessor<*>, + defaultValue: PropertyValue<T>, + params: ConfigParams.() -> Unit = {}, + propertyKeyCallback: (PropertyKey<*>) -> Unit = {} + ): PropertyValue<T> { + val propertyKey = PropertyKey({ parentContainerKey }, key, type, ConfigParams().also { it.params() }) + properties[propertyKey] = defaultValue + propertyKeyCallback(propertyKey) + return defaultValue + } + + protected fun boolean(key: String, defaultValue: Boolean = false, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.BOOLEAN, PropertyValue(defaultValue), params) + + protected fun integer(key: String, defaultValue: Int = 0, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.INTEGER, PropertyValue(defaultValue), params) + + protected fun float(key: String, defaultValue: Float = 0f, params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.FLOAT, PropertyValue(defaultValue), params) + + protected fun string(key: String, defaultValue: String = "", params: ConfigParamsBuilder = {}) = + registerProperty(key, DataProcessors.STRING, PropertyValue(defaultValue), params) + + protected fun multiple( + key: String, + vararg values: String = emptyArray(), + params: ConfigParamsBuilder = {} + ) = registerProperty(key, + DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(mutableListOf<String>(), defaultValues = values.toList()), params) + + //null value is considered as Off/Disabled + protected fun unique( + key: String, + vararg values: String = emptyArray(), + params: ConfigParamsBuilder = {} + ) = registerProperty(key, + DataProcessors.STRING_UNIQUE_SELECTION, PropertyValue("null", defaultValues = values.toList()), params) + + protected fun <T : ConfigContainer> container( + key: String, + container: T, + params: ConfigParamsBuilder = {} + ) = registerProperty(key, DataProcessors.container(container), PropertyValue(container), params) { + container.parentContainerKey = it + }.get() + + fun toJson(): JsonObject { + val json = JsonObject() + properties.forEach { (propertyKey, propertyValue) -> + val serializedValue = propertyValue.getNullable()?.let { propertyKey.dataType.serializeAny(it) } + json.add(propertyKey.name, serializedValue) + } + return json + } + + 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)) + } + } + + operator fun getValue(t: Any?, property: KProperty<*>) = this.globalState + operator fun setValue(t: Any?, property: KProperty<*>, t1: Boolean?) { this.globalState = t1 } +}+ \ No newline at end of file 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 @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.common.config + +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import kotlin.reflect.KProperty + +data class PropertyPair<T>( + val key: PropertyKey<T>, + val value: PropertyValue<*> +) { + val name get() = key.name +} + +enum class FeatureNotice( + val id: Int, + val key: String +) { + UNSTABLE(0b0001, "unstable"), + BAN_RISK(0b0010, "ban_risk"), + INTERNAL_BEHAVIOR(0b0100, "internal_behavior") +} + +enum class ConfigFlag( + val id: Int +) { + NO_TRANSLATE(0b000001), + HIDDEN(0b000010), + FOLDER(0b000100), + NO_DISABLE_KEY(0b001000), + REQUIRE_RESTART(0b010000), + REQUIRE_CLEAN_CACHE(0b100000) +} + +class ConfigParams( + private var _flags: Int? = null, + private var _notices: Int? = null, + + var icon: String? = null, + var disabledKey: String? = null, + var customTranslationPath: String? = null, + var customOptionTranslationPath: String? = null +) { + 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() + + fun addNotices(vararg values: FeatureNotice) { + this._notices = (this._notices ?: 0) or values.fold(0) { acc, featureNotice -> acc or featureNotice.id } + } + + fun addFlags(vararg values: ConfigFlag) { + this._flags = (this._flags ?: 0) or values.fold(0) { acc, flag -> acc or flag.id } + } + + fun requireRestart() { + addFlags(ConfigFlag.REQUIRE_RESTART) + } + fun requireCleanCache() { + addFlags(ConfigFlag.REQUIRE_CLEAN_CACHE) + } +} + +class PropertyValue<T>( + private var value: T? = null, + val defaultValues: List<*>? = null +) { + inner class PropertyValueNullable { + fun get() = value + operator fun getValue(t: Any?, property: KProperty<*>): T? = getNullable() + operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) + } + + fun nullable() = PropertyValueNullable() + + 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?) { setAny(value) } + @Suppress("UNCHECKED_CAST") + fun setAny(value: Any?) { this.value = value as T? } + + operator fun getValue(t: Any?, property: KProperty<*>): T = get() + operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) +} + +data class PropertyKey<T>( + private val _parent: () -> PropertyKey<*>?, + val name: String, + val dataType: DataProcessors.PropertyDataProcessor<T>, + val params: ConfigParams = ConfigParams(), +) { + private val parentKey by lazy { _parent() } + + fun propertyOption(translation: LocaleWrapper, key: String): String { + if (key == "null") { + return translation[params.disabledKey ?: "manager.sections.features.disabled"] + } + + return if (!params.flags.contains(ConfigFlag.NO_TRANSLATE)) + translation[params.customOptionTranslationPath?.let { + "$it.$key" + } ?: "features.options.${name}.$key"] + else key + } + + fun propertyName() = propertyTranslationPath() + ".name" + fun propertyDescription() = propertyTranslationPath() + ".description" + + fun propertyTranslationPath(): String { + params.customTranslationPath?.let { + return it + } + return parentKey?.let { + "${it.propertyTranslationPath()}.properties.$name" + } ?: "features.properties.$name" + } +} + diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/DataProcessors.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/DataProcessors.kt @@ -0,0 +1,94 @@ +package me.rhunk.snapenhance.common.config + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive + +object DataProcessors { + enum class Type { + STRING, + BOOLEAN, + INTEGER, + FLOAT, + STRING_MULTIPLE_SELECTION, + STRING_UNIQUE_SELECTION, + CONTAINER, + } + + data class PropertyDataProcessor<T> + internal constructor( + val type: Type, + private val serialize: (T) -> JsonElement, + private val deserialize: (JsonElement) -> T + ) { + @Suppress("UNCHECKED_CAST") + fun serializeAny(value: Any) = serialize(value as T) + fun deserializeAny(value: JsonElement) = deserialize(value) + } + + val STRING = PropertyDataProcessor( + type = Type.STRING, + serialize = { + if (it != null) JsonPrimitive(it) + else JsonNull.INSTANCE + }, + deserialize = { + if (it.isJsonNull) null + else it.asString + }, + ) + + val BOOLEAN = PropertyDataProcessor( + type = Type.BOOLEAN, + serialize = { + if (it) JsonPrimitive(true) + else JsonPrimitive(false) + }, + deserialize = { it.asBoolean }, + ) + + val INTEGER = PropertyDataProcessor( + type = Type.INTEGER, + serialize = { JsonPrimitive(it) }, + deserialize = { it.asInt }, + ) + + val FLOAT = PropertyDataProcessor( + type = Type.FLOAT, + serialize = { JsonPrimitive(it) }, + deserialize = { it.asFloat }, + ) + + val STRING_MULTIPLE_SELECTION = PropertyDataProcessor( + type = Type.STRING_MULTIPLE_SELECTION, + serialize = { JsonArray().apply { it.forEach { add(it) } } }, + deserialize = { obj -> + obj.asJsonArray.map { it.asString }.toMutableList() + }, + ) + + val STRING_UNIQUE_SELECTION = PropertyDataProcessor( + type = Type.STRING_UNIQUE_SELECTION, + serialize = { JsonPrimitive(it) }, + deserialize = { obj -> obj.takeIf { !it.isJsonNull }?.asString } + ) + + fun <T : ConfigContainer> container(container: T) = PropertyDataProcessor( + type = Type.CONTAINER, + serialize = { + JsonObject().apply { + addProperty("state", it.globalState) + add("properties", it.toJson()) + } + }, + deserialize = { obj -> + val jsonObject = obj.asJsonObject + container.apply { + globalState = jsonObject["state"].takeIf { !it.isJsonNull }?.asBoolean + fromJson(jsonObject["properties"].asJsonObject) + } + }, + ) +}+ \ 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 @@ -0,0 +1,131 @@ +package me.rhunk.snapenhance.common.config + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import me.rhunk.snapenhance.bridge.ConfigStateListener +import me.rhunk.snapenhance.common.bridge.FileLoaderWrapper +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.config.impl.RootConfig +import me.rhunk.snapenhance.common.logger.AbstractLogger +import kotlin.properties.Delegates + +class ModConfig { + var locale: String = LocaleWrapper.DEFAULT_LOCALE + + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + private val file = FileLoaderWrapper(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8)) + var wasPresent by Delegates.notNull<Boolean>() + + /* Used to notify the bridge client about config changes */ + var configStateListener: ConfigStateListener? = null + lateinit var root: RootConfig + private set + + private fun load() { + root = RootConfig() + wasPresent = file.isFileExists() + if (!file.isFileExists()) { + writeConfig() + return + } + + runCatching { + loadConfig() + }.onFailure { + writeConfig() + } + } + + private fun loadConfig() { + val configFileContent = file.read() + val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java) + locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE + root.fromJson(configObject) + } + + fun exportToString(): String { + val configObject = root.toJson() + configObject.addProperty("_locale", locale) + return configObject.toString() + } + + fun reset() { + root = RootConfig() + writeConfig() + } + + fun writeConfig() { + var shouldRestart = false + var shouldCleanCache = false + var configChanged = false + + fun compareDiff(originalContainer: ConfigContainer, modifiedContainer: ConfigContainer) { + val parentContainerFlags = modifiedContainer.parentContainerKey?.params?.flags ?: emptySet() + + parentContainerFlags.takeIf { originalContainer.hasGlobalState }?.apply { + if (modifiedContainer.globalState != originalContainer.globalState) { + configChanged = true + if (contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true + if (contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true + } + } + + for (property in modifiedContainer.properties) { + val modifiedValue = property.value.getNullable() + val originalValue = originalContainer.properties.entries.firstOrNull { + it.key.name == property.key.name + }?.value?.getNullable() + + if (originalValue is ConfigContainer && modifiedValue is ConfigContainer) { + compareDiff(originalValue, modifiedValue) + continue + } + + if (modifiedValue != originalValue) { + val flags = property.key.params.flags + parentContainerFlags + configChanged = true + if (flags.contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true + if (flags.contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true + } + } + } + + configStateListener?.also { + runCatching { + compareDiff(RootConfig().apply { + fromJson(gson.fromJson(file.read().toString(Charsets.UTF_8), JsonObject::class.java)) + }, root) + + if (configChanged) { + it.onConfigChanged() + if (shouldCleanCache) it.onCleanCacheRequired() + else if (shouldRestart) it.onRestartRequired() + } + }.onFailure { + AbstractLogger.directError("Error while calling config state listener", it, "ConfigStateListener") + } + } + + file.write(exportToString().toByteArray(Charsets.UTF_8)) + } + + fun loadFromString(string: String) { + val configObject = gson.fromJson(string, JsonObject::class.java) + locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE + root.fromJson(configObject) + writeConfig() + } + + fun loadFromContext(context: Context) { + file.loadFromContext(context) + load() + } + + fun loadFromCallback(callback: (FileLoaderWrapper) -> Unit) { + callback(file) + load() + } +}+ \ No newline at end of file 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 @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Camera : ConfigContainer() { + companion object { + val resolutions = listOf("3264x2448", "3264x1840", "3264x1504", "2688x1512", "2560x1920", "2448x2448", "2340x1080", "2160x1080", "1920x1440", "1920x1080", "1600x1200", "1600x960", "1600x900", "1600x736", "1600x720", "1560x720", "1520x720", "1440x1080", "1440x720", "1280x720", "1080x1080", "1080x720", "960x720", "720x720", "720x480", "640x480", "352x288", "320x240", "176x144").toTypedArray() + } + + val disable = boolean("disable_camera") + val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE) } + val overridePreviewResolution = unique("override_preview_resolution", *resolutions) + { addFlags(ConfigFlag.NO_TRANSLATE) } + val overridePictureResolution = unique("override_picture_resolution", *resolutions) + { addFlags(ConfigFlag.NO_TRANSLATE) } + 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") +} 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 @@ -0,0 +1,50 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.ConfigFlag +import me.rhunk.snapenhance.common.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 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) } + } + + val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER); requireRestart() } + val autoDownloadSources = multiple("auto_download_sources", + "friend_snaps", + "friend_stories", + "public_stories", + "spotlight" + ) + val preventSelfAutoDownload = boolean("prevent_self_auto_download") + val pathFormat = multiple("path_format", + "create_author_folder", + "create_source_folder", + "append_hash", + "append_source", + "append_username", + "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.UNSTABLE) } + 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 downloadProfilePictures = boolean("download_profile_pictures") { 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 { + set(mutableListOf("started", "success")) + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/E2EEConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/E2EEConfig.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer + +class E2EEConfig : ConfigContainer(hasGlobalState = true) { + val encryptedMessageIndicator = boolean("encrypted_message_indicator") + val forceMessageEncryption = boolean("force_message_encryption") +}+ \ No newline at end of file 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 @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Experimental : ConfigContainer() { + val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } + val spoof = container("spoof", Spoof()) { icon = "Fingerprint" } + val appPasscode = string("app_passcode") + 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.BAN_RISK)} + val noFriendScoreDelay = boolean("no_friend_score_delay") { requireRestart()} + val e2eEncryption = container("e2ee", E2EEConfig()) { requireRestart()} + val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { + addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) + requireRestart() + } + 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.BAN_RISK) } +}+ \ No newline at end of file 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 @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Global : ConfigContainer() { + val snapchatPlus = boolean("snapchat_plus") { addNotices(FeatureNotice.BAN_RISK); requireRestart() } + val disableMetrics = boolean("disable_metrics") + val blockAds = boolean("block_ads") + val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices( + FeatureNotice.BAN_RISK); requireRestart() } + val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") { requireRestart() } + val forceMediaSourceQuality = boolean("force_media_source_quality") + 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/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -0,0 +1,31 @@ +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.data.NotificationType + +class MessagingTweaks : ConfigContainer() { + val anonymousStoryViewing = boolean("anonymous_story_viewing") + 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", + "NOTE", + "EXTERNAL_MEDIA", + "STICKER" + ) { requireRestart() } + val snapToChatMedia = boolean("snap_to_chat_media") { requireRestart() } + 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") { requireRestart() } + val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { + customOptionTranslationPath = "features.options.notifications" + } + val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + val galleryMediaSendOverride = boolean("gallery_media_send_override") + val messagePreviewLength = integer("message_preview_length", defaultValue = 20) +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/NativeHooks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/NativeHooks.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer + +class NativeHooks: ConfigContainer(hasGlobalState = true) { + val disableBitmoji = boolean("disable_bitmoji") + val fixGalleryMediaOverride = boolean("fix_gallery_media_override") +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt @@ -0,0 +1,18 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice + +class RootConfig : ConfigContainer() { + val downloader = container("downloader", DownloaderConfig()) { icon = "Download"} + val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"} + val messaging = container("messaging", MessagingTweaks()) { icon = "Send" } + val global = container("global", Global()) { icon = "MiscellaneousServices" } + val rules = container("rules", Rules()) { icon = "Rule" } + val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() } + val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } + val experimental = container("experimental", Experimental()) { + icon = "Science"; addNotices(FeatureNotice.UNSTABLE) + } + val scripting = container("scripting", Scripting()) { icon = "DataObject" } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Rules.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Rules.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.PropertyValue +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState + + +class Rules : ConfigContainer() { + private val rules = mutableMapOf<MessagingRuleType, PropertyValue<String>>() + + fun getRuleState(ruleType: MessagingRuleType): RuleState? { + return rules[ruleType]?.getNullable()?.let { RuleState.getByName(it) } + } + + init { + MessagingRuleType.entries.filter { it.listMode }.forEach { ruleType -> + rules[ruleType] = unique(ruleType.key,"whitelist", "blacklist") { + customTranslationPath = "rules.properties.${ruleType.key}" + customOptionTranslationPath = "rules.modes" + }.apply { + set("whitelist") + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,10 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +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) +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Spoof.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Spoof.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer +import me.rhunk.snapenhance.common.config.FeatureNotice + +class Spoof : ConfigContainer() { + inner class Location : ConfigContainer(hasGlobalState = true) { + val latitude = float("location_latitude") + val longitude = float("location_longitude") + } + val location = container("location", Location()) + + inner class Device : ConfigContainer(hasGlobalState = true) { + 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 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/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 @@ -0,0 +1,9 @@ +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 remainingHours = integer("remaining_hours", 13) + val groupNotifications = boolean("group_notifications", true) +}+ \ 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 @@ -0,0 +1,45 @@ +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.data.MessagingRuleType + +class UserInterfaceTweaks : ConfigContainer() { + class BootstrapOverride : ConfigContainer() { + companion object { + val tabs = arrayOf("map", "chat", "camera", "discover", "spotlight") + } + + val appAppearance = unique("app_appearance", "always_light", "always_dark") + val homeTab = unique("home_tab", *tabs) { addNotices(FeatureNotice.UNSTABLE) } + } + + inner class FriendFeedMessagePreview : ConfigContainer(hasGlobalState = true) { + val amount = integer("amount", defaultValue = 1) + } + + val friendFeedMenuButtons = multiple( + "friend_feed_menu_buttons","conversation_info", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() + ).apply { + set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) + } + 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 bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } + val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } + val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } + val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() } + val hideStorySections = multiple("hide_story_sections", + "hide_friend_suggestions", "hide_friends", "hide_suggested", "hide_for_you") { requireRestart() } + val hideUiComponents = multiple("hide_ui_components", + "hide_voice_record_button", + "hide_stickers_button", + "hide_live_location_share_button", + "hide_chat_call_buttons", + "hide_profile_call_buttons" + ) { requireRestart() } + val ddBitmojiSelfie = boolean("2d_bitmoji_selfie") { requireCleanCache() } + val disableSpotlight = boolean("disable_spotlight") { requireRestart() } + val storyViewerOverride = unique("story_viewer_override", "DISCOVER_PLAYBACK_SEEKBAR", "VERTICAL_STORY_VIEWER") { requireRestart() } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.common.data + +import java.io.File +import java.io.InputStream + +enum class FileType( + val fileExtension: String? = null, + val mimeType: String, + val isVideo: Boolean = false, + val isImage: Boolean = false, + val isAudio: Boolean = false +) { + GIF("gif", "image/gif", false, false, false), + 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), + MPD("mpd", "text/xml", false, false, false), + UNKNOWN("dat", "application/octet-stream", false, false, false); + + companion object { + private val fileSignatures = mapOf( + "52494646" to WEBP, + "504b0304" to ZIP, + "89504e47" to PNG, + "00000020" to MP4, + "00000018" to MP4, + "0000001c" to MP4, + "494433" to MP3, + "4f676753" to OPUS, + "fff15" to AAC, + "ffd8ff" to JPG, + ) + + fun fromString(string: String?): FileType { + return entries.firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN + } + + private fun bytesToHex(bytes: ByteArray): String { + val result = StringBuilder() + for (b in bytes) { + result.append(String.format("%02x", b)) + } + return result.toString() + } + + fun fromFile(file: File): FileType { + file.inputStream().use { inputStream -> + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } + } + + fun fromByteArray(array: ByteArray): 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 + } + + fun fromInputStream(inputStream: InputStream): FileType { + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } + } +} 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 @@ -0,0 +1,73 @@ +package me.rhunk.snapenhance.common.data + +import me.rhunk.snapenhance.common.util.SerializableDataObject + + +enum class RuleState( + val key: String +) { + BLACKLIST("blacklist"), + WHITELIST("whitelist"); + + companion object { + fun getByName(name: String) = entries.first { it.key == name } + } +} + +enum class SocialScope( + val key: String, + val tabRoute: String, +) { + FRIEND("friend", "friend_info/{id}"), + GROUP("group", "group_info/{id}"); + + companion object { + fun getByName(name: String) = entries.first { it.key == name } + } +} + +enum class MessagingRuleType( + val key: String, + val listMode: Boolean, + val showInFriendMenu: Boolean = true +) { + AUTO_DOWNLOAD("auto_download", true), + STEALTH("stealth", true), + AUTO_SAVE("auto_save", true), + HIDE_CHAT_FEED("hide_chat_feed", false, showInFriendMenu = false), + E2E_ENCRYPTION("e2e_encryption", false), + PIN_CONVERSATION("pin_conversation", false, showInFriendMenu = false); + + fun translateOptionKey(optionKey: String): String { + return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name" + } + + companion object { + fun getByName(name: String) = entries.firstOrNull { it.key == name } + } +} + +data class FriendStreaks( + val userId: String, + val notify: Boolean, + val expirationTimestamp: Long, + val length: Int +) : SerializableDataObject() { + fun hoursLeft() = (expirationTimestamp - System.currentTimeMillis()) / 1000 / 60 / 60 + + fun isAboutToExpire(expireHours: Int) = expirationTimestamp - System.currentTimeMillis() < expireHours * 60 * 60 * 1000 +} + +data class MessagingGroupInfo( + val conversationId: String, + val name: String, + val participantsCount: Int +) : SerializableDataObject() + +data class MessagingFriendInfo( + val userId: String, + val displayName: String?, + val mutableUsername: String, + val bitmojiId: String?, + val selfieId: String? +) : SerializableDataObject() 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 @@ -0,0 +1,172 @@ +package me.rhunk.snapenhance.common.data + +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader + +enum class MessageState { + PREPARING, SENDING, COMMITTED, FAILED, CANCELING +} + +enum class NotificationType ( + val key: String, + val isIncoming: Boolean = false, + val associatedOutgoingContentType: ContentType? = null, +) { + SCREENSHOT("chat_screenshot", true, ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT), + SCREEN_RECORD("chat_screen_record", true, ContentType.STATUS_CONVERSATION_CAPTURE_RECORD), + CAMERA_ROLL_SAVE("camera_roll_save", true, ContentType.STATUS_SAVE_TO_CAMERA_ROLL), + SNAP_REPLAY("snap_replay", true, ContentType.STATUS), + SNAP("snap",true), + CHAT("chat",true), + CHAT_REPLY("chat_reply",true), + TYPING("typing", true), + STORIES("stories",true), + INITIATE_AUDIO("initiate_audio",true), + ABANDON_AUDIO("abandon_audio", false, ContentType.STATUS_CALL_MISSED_AUDIO), + INITIATE_VIDEO("initiate_video",true), + ABANDON_VIDEO("abandon_video", false, ContentType.STATUS_CALL_MISSED_VIDEO); + + companion object { + fun getIncomingValues(): List<NotificationType> { + return entries.filter { it.isIncoming }.toList() + } + + fun getOutgoingValues(): List<NotificationType> { + return entries.filter { it.associatedOutgoingContentType != null }.toList() + } + + fun fromContentType(contentType: ContentType): NotificationType? { + return entries.firstOrNull { it.associatedOutgoingContentType == contentType } + } + } +} + +enum class ContentType(val id: Int) { + UNKNOWN(-1), + SNAP(0), + CHAT(1), + EXTERNAL_MEDIA(2), + SHARE(3), + NOTE(4), + STICKER(5), + STATUS(6), + LOCATION(7), + STATUS_SAVE_TO_CAMERA_ROLL(8), + STATUS_CONVERSATION_CAPTURE_SCREENSHOT(9), + STATUS_CONVERSATION_CAPTURE_RECORD(10), + STATUS_CALL_MISSED_VIDEO(11), + STATUS_CALL_MISSED_AUDIO(12), + LIVE_LOCATION_SHARE(13), + CREATIVE_TOOL_ITEM(14), + FAMILY_CENTER_INVITE(15), + FAMILY_CENTER_ACCEPT(16), + FAMILY_CENTER_LEAVE(17), + STATUS_PLUS_GIFT(18); + + companion object { + fun fromId(i: Int): ContentType { + return entries.firstOrNull { it.id == i } ?: UNKNOWN + } + + fun fromMessageContainer(protoReader: ProtoReader?): ContentType? { + if (protoReader == null) return null + return protoReader.run { + when { + contains(8) -> STATUS + contains(2) -> CHAT + contains(11) -> SNAP + contains(6) -> NOTE + contains(3) -> EXTERNAL_MEDIA + contains(4) -> STICKER + contains(5) -> SHARE + contains(7) -> EXTERNAL_MEDIA // story replies + else -> null + } + } + } + } +} + +enum class PlayableSnapState { + NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE +} + +enum class MetricsMessageMediaType { + NO_MEDIA, + IMAGE, + VIDEO, + VIDEO_NO_SOUND, + GIF, + DERIVED_FROM_MESSAGE_TYPE, + REACTION +} + +enum class MetricsMessageType { + TEXT, + STICKER, + CUSTOM_STICKER, + SNAP, + AUDIO_NOTE, + MEDIA, + BATCHED_MEDIA, + MISSED_AUDIO_CALL, + MISSED_VIDEO_CALL, + JOINED_CALL, + LEFT_CALL, + SNAPCHATTER, + LOCATION_SHARE, + LOCATION_REQUEST, + SCREENSHOT, + SCREEN_RECORDING, + GAME_CLOSED, + STORY_SHARE, + MAP_DROP_SHARE, + MAP_STORY_SHARE, + MAP_STORY_SNAP_SHARE, + MAP_HEAT_SNAP_SHARE, + MAP_SCREENSHOT_SHARE, + MEMORIES_STORY, + SEARCH_STORY_SHARE, + SEARCH_STORY_SNAP_SHARE, + DISCOVER_SHARE, + SHAZAM_SHARE, + SAVE_TO_CAMERA_ROLL, + GAME_SCORE_SHARE, + SNAP_PRO_PROFILE_SHARE, + SNAP_PRO_SNAP_SHARE, + CANVAS_APP_SHARE, + AD_SHARE, + STORY_REPLY, + SPOTLIGHT_STORY_SHARE, + CAMEO, + MEMOJI, + BITMOJI_OUTFIT_SHARE, + LIVE_LOCATION_SHARE, + CREATIVE_TOOL_ITEM, + SNAP_KIT_INVITE_SHARE, + QUOTE_REPLY_SHARE, + BLOOPS_STORY_SHARE, + SNAP_PRO_SAVED_STORY_SHARE, + PLACE_PROFILE_SHARE, + PLACE_STORY_SHARE, + SAVED_STORY_SHARE +} +enum class MediaReferenceType { + UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO +} + +enum class FriendLinkType(val value: Int, val shortName: String) { + MUTUAL(0, "mutual"), + OUTGOING(1, "outgoing"), + BLOCKED(2, "blocked"), + DELETED(3, "deleted"), + FOLLOWING(4, "following"), + SUGGESTED(5, "suggested"), + INCOMING(6, "incoming"), + INCOMING_FOLLOWER(7, "incoming_follower"); + + companion object { + fun fromValue(value: Int): FriendLinkType { + return entries.firstOrNull { it.value == value } ?: MUTUAL + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMediaType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadMediaType.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.data.download + +import android.net.Uri + +enum class DownloadMediaType { + PROTO_MEDIA, + DIRECT_MEDIA, + REMOTE_MEDIA, + LOCAL_MEDIA; + + companion object { + fun fromUri(uri: Uri): DownloadMediaType { + return when (uri.scheme) { + "proto" -> PROTO_MEDIA + "direct" -> DIRECT_MEDIA + "http", "https" -> REMOTE_MEDIA + "file" -> LOCAL_MEDIA + else -> throw IllegalArgumentException("Unknown uri scheme: ${uri.scheme}") + } + } + } +}+ \ 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 @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.common.data.download + +data class DownloadMetadata( + val mediaIdentifier: String?, + val outputPath: String, + val mediaAuthor: String?, + val downloadSource: String, + val iconUrl: String? +)+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.common.data.download + + +data class DashOptions(val offsetTime: Long, val duration: Long?) +data class InputMedia( + val content: String, + val type: DownloadMediaType, + val encryption: MediaEncryptionKeyPair? = null, + val attachmentType: String? = null, + val isOverlay: Boolean = false, +) + +class DownloadRequest( + val inputMedias: Array<InputMedia>, + val dashOptions: DashOptions? = null, + private val flags: Int = 0, +) { + object Flags { + const val MERGE_OVERLAY = 1 + const val IS_DASH_PLAYLIST = 2 + } + + val isDashPlaylist: Boolean + get() = flags and Flags.IS_DASH_PLAYLIST != 0 + + val shouldMergeOverlay: Boolean + get() = flags and Flags.MERGE_OVERLAY != 0 +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadStage.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadStage.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.common.data.download + +enum class DownloadStage( + val isFinalStage: Boolean = false, +) { + PENDING(false), + DOWNLOADING(false), + MERGING(false), + DOWNLOADED(true), + SAVED(true), + MERGE_FAILED(true), + FAILED(true), + CANCELLED(true) +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt @@ -0,0 +1,28 @@ +package me.rhunk.snapenhance.common.data.download + +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 entries.find { it.key == key } ?: NONE + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaEncryptionKeyPair.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalEncodingApi::class) + +package me.rhunk.snapenhance.common.data.download + +import java.io.InputStream +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 + +// key and iv are base64 encoded into url safe strings +data class MediaEncryptionKeyPair( + val key: String, + val iv: String +) { + fun decryptInputStream(inputStream: InputStream): InputStream { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.UrlSafe.decode(key), "AES"), IvParameterSpec(Base64.UrlSafe.decode(iv))) + return CipherInputStream(inputStream, cipher) + } +} + +fun Pair<ByteArray, ByteArray>.toKeyPair() + = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second)) diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/SplitMediaAssetType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/SplitMediaAssetType.kt @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.common.data.download + +enum class SplitMediaAssetType { + ORIGINAL, OVERLAY +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/DatabaseObject.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/DatabaseObject.kt @@ -0,0 +1,7 @@ +package me.rhunk.snapenhance.common.database + +import android.database.Cursor + +interface DatabaseObject { + fun write(cursor: Cursor) +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/ConversationMessage.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/ConversationMessage.kt @@ -0,0 +1,40 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +@Suppress("ArrayInDataClass") +data class ConversationMessage( + var clientConversationId: String? = null, + var clientMessageId: Int = 0, + var serverMessageId: Int = 0, + var messageContent: ByteArray? = null, + var isSaved: Int = 0, + var isViewedByUser: Int = 0, + var contentType: Int = 0, + var creationTimestamp: Long = 0, + var readTimestamp: Long = 0, + var senderId: String? = null +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + clientConversationId = getStringOrNull("client_conversation_id") + clientMessageId = getInteger("client_message_id") + serverMessageId = getInteger("server_message_id") + messageContent = getBlobOrNull("message_content") + isSaved = getInteger("is_saved") + isViewedByUser = getInteger("is_viewed_by_user") + contentType = getInteger("content_type") + creationTimestamp = getLong("creation_timestamp") + readTimestamp = getLong("read_timestamp") + senderId = getStringOrNull("sender_id") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendFeedEntry.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/FriendFeedEntry.kt @@ -0,0 +1,47 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getIntOrNull +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class FriendFeedEntry( + var id: Int = 0, + var feedDisplayName: String? = null, + var participantsSize: Int = 0, + var lastInteractionTimestamp: Long = 0, + var displayTimestamp: Long = 0, + var displayInteractionType: String? = null, + var lastInteractionUserId: Int? = null, + var key: String? = null, + var friendUserId: String? = null, + var friendDisplayName: String? = null, + var friendDisplayUsername: String? = null, + var friendLinkType: Int? = null, + var bitmojiAvatarId: String? = null, + var bitmojiSelfieId: String? = null, +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + id = getInteger("_id") + feedDisplayName = getStringOrNull("feedDisplayName") + participantsSize = getInteger("participantsSize") + lastInteractionTimestamp = getLong("lastInteractionTimestamp") + displayTimestamp = getLong("displayTimestamp") + displayInteractionType = getStringOrNull("displayInteractionType") + lastInteractionUserId = getIntOrNull("lastInteractionUserId") + key = getStringOrNull("key") + friendUserId = getStringOrNull("friendUserId") + friendDisplayName = getStringOrNull("friendDisplayName") + friendDisplayUsername = getStringOrNull("friendDisplayUsername") + friendLinkType = getIntOrNull("friendLinkType") + bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") + bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") + } + } +} 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 @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.SerializableDataObject +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLong +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class FriendInfo( + var id: Int = 0, + var lastModifiedTimestamp: Long = 0, + var username: String? = null, + var userId: String? = null, + var displayName: String? = null, + var bitmojiAvatarId: String? = null, + var bitmojiSelfieId: String? = null, + var bitmojiSceneId: String? = null, + var bitmojiBackgroundId: String? = null, + var friendmojis: String? = null, + var friendmojiCategories: String? = null, + var snapScore: Int = 0, + var birthday: Long = 0, + var addedTimestamp: Long = 0, + var reverseAddedTimestamp: Long = 0, + var serverDisplayName: String? = null, + var streakLength: Int = 0, + var streakExpirationTimestamp: Long = 0, + var reverseBestFriendRanking: Int = 0, + var isPinnedBestFriend: Int = 0, + var plusBadgeVisibility: Int = 0, + var usernameForSorting: String? = null, + var friendLinkType: Int = 0, + var postViewEmoji: String? = null, +) : DatabaseObject, SerializableDataObject() { + val mutableUsername get() = username?.split("|")?.last() + val firstCreatedUsername get() = username?.split("|")?.first() + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + id = getInteger("_id") + lastModifiedTimestamp = getLong("_lastModifiedTimestamp") + username = getStringOrNull("username") + userId = getStringOrNull("userId") + displayName = getStringOrNull("displayName") + bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") + bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") + bitmojiSceneId = getStringOrNull("bitmojiSceneId") + bitmojiBackgroundId = getStringOrNull("bitmojiBackgroundId") + friendmojis = getStringOrNull("friendmojis") + friendmojiCategories = getStringOrNull("friendmojiCategories") + snapScore = getInteger("score") + birthday = getLong("birthday") + addedTimestamp = getLong("addedTimestamp") + reverseAddedTimestamp = getLong("reverseAddedTimestamp") + serverDisplayName = getStringOrNull("serverDisplayName") + streakLength = getInteger("streakLength") + streakExpirationTimestamp = getLong("streakExpiration") + reverseBestFriendRanking = getInteger("reverseBestFriendRanking") + usernameForSorting = getStringOrNull("usernameForSorting") + friendLinkType = getInteger("friendLinkType") + postViewEmoji = getStringOrNull("postViewEmoji") + if (getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend = + getInteger("isPinnedBestFriend") + if (getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility = + getInteger("plusBadgeVisibility") + } + } + +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StoryEntry.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/StoryEntry.kt @@ -0,0 +1,27 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +data class StoryEntry( + var id: Int = 0, + var storyId: String? = null, + var displayName: String? = null, + var isLocal: Boolean? = null, + var userId: String? = null +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + id = getInteger("_id") + storyId = getStringOrNull("storyId") + displayName = getStringOrNull("displayName") + isLocal = getInteger("isLocal") == 1 + userId = getStringOrNull("userId") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/UserConversationLink.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/database/impl/UserConversationLink.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.common.database.impl + +import android.annotation.SuppressLint +import android.database.Cursor +import me.rhunk.snapenhance.common.database.DatabaseObject +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + +class UserConversationLink( + var userId: String? = null, + var clientConversationId: String? = null, + var conversationType: Int = 0 +) : DatabaseObject { + + @SuppressLint("Range") + override fun write(cursor: Cursor) { + with(cursor) { + userId = getStringOrNull("user_id") + clientConversationId = getStringOrNull("client_conversation_id") + conversationType = getInteger("conversation_type") + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/AbstractLogger.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/AbstractLogger.kt @@ -0,0 +1,38 @@ +package me.rhunk.snapenhance.common.logger + +import android.util.Log + +abstract class AbstractLogger( + logChannel: LogChannel, +) { + private val TAG = logChannel.shortName + + companion object { + + private const val TAG = "SnapEnhanceCommon" + + fun directDebug(message: Any?, tag: String = TAG) { + Log.println(Log.DEBUG, tag, message.toString()) + } + + fun directError(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.ERROR, tag, message.toString()) + Log.println(Log.ERROR, tag, throwable.toString()) + } + + } + + open fun debug(message: Any?, tag: String = TAG) {} + + open fun error(message: Any?, tag: String = TAG) {} + + open fun error(message: Any?, throwable: Throwable, tag: String = TAG) {} + + open fun info(message: Any?, tag: String = TAG) {} + + open fun verbose(message: Any?, tag: String = TAG) {} + + open fun warn(message: Any?, tag: String = TAG) {} + + open fun assert(message: Any?, tag: String = TAG) {} +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogChannel.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogChannel.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.logger + +enum class LogChannel( + 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): LogChannel? { + return entries.find { it.channel == channel } + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogLevel.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/logger/LogLevel.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.common.logger + +import android.util.Log + +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); + + companion object { + fun fromLetter(letter: String): LogLevel? { + return entries.find { it.letter == letter } + } + + fun fromShortName(shortName: String): LogLevel? { + return entries.find { it.shortName == shortName } + } + + fun fromPriority(priority: Int): LogLevel? { + return entries.find { it.priority == priority } + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/IPCInterface.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/IPCInterface.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.scripting + +typealias Listener = (Array<out String?>) -> Unit + +abstract class IPCInterface { + abstract fun on(eventName: String, listener: Listener) + + abstract fun onBroadcast(channel: String, eventName: String, listener: Listener) + + abstract fun emit(eventName: String, vararg args: String?) + abstract fun broadcast(channel: String, eventName: String, vararg args: String?) + + @Suppress("unused") + fun emit(eventName: String) = emit(eventName, *emptyArray()) + @Suppress("unused") + fun emit(channel: String, eventName: String) = broadcast(channel, eventName) +}+ \ 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 @@ -0,0 +1,134 @@ +package me.rhunk.snapenhance.common.scripting + +import android.os.Handler +import android.widget.Toast +import me.rhunk.snapenhance.common.scripting.ktx.contextScope +import me.rhunk.snapenhance.common.scripting.ktx.putFunction +import me.rhunk.snapenhance.common.scripting.ktx.scriptableObject +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import org.mozilla.javascript.Function +import org.mozilla.javascript.NativeJavaObject +import org.mozilla.javascript.ScriptableObject +import org.mozilla.javascript.Undefined +import org.mozilla.javascript.Wrapper +import java.lang.reflect.Modifier + +class JSModule( + val scriptRuntime: ScriptRuntime, + val moduleInfo: ModuleInfo, + val content: String, +) { + private lateinit var moduleObject: ScriptableObject + + fun load(block: ScriptableObject.() -> Unit) { + contextScope { + val classLoader = scriptRuntime.androidContext.classLoader + moduleObject = initSafeStandardObjects() + moduleObject.putConst("module", moduleObject, scriptableObject { + putConst("info", this, scriptableObject { + putConst("name", this, moduleInfo.name) + putConst("version", this, moduleInfo.version) + putConst("description", this, moduleInfo.description) + putConst("author", this, moduleInfo.author) + putConst("minSnapchatVersion", this, moduleInfo.minSnapchatVersion) + putConst("minSEVersion", this, moduleInfo.minSEVersion) + putConst("grantPermissions", this, moduleInfo.grantPermissions) + }) + }) + + moduleObject.putFunction("setField") { args -> + val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance + val name = args[1].toString() + val value = args[2] + val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.set(obj.unwrap(), value.toPrimitiveValue(lazy { field.type.name })) + Undefined.instance + } + + moduleObject.putFunction("getField") { args -> + val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance + val name = args[1].toString() + val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance + field.isAccessible = true + field.get(obj.unwrap()) + } + + moduleObject.putFunction("findClass") { + val className = it?.get(0).toString() + classLoader.loadClass(className) + } + + moduleObject.putFunction("type") { args -> + val className = args?.get(0).toString() + val clazz = classLoader.loadClass(className) + + scriptableObject("JavaClassWrapper") { + putFunction("newInstance") newInstance@{ args -> + val constructor = clazz.declaredConstructors.find { + it.parameterCount == (args?.size ?: 0) + } ?: return@newInstance Undefined.instance + constructor.newInstance(*args ?: emptyArray()) + } + + clazz.declaredMethods.filter { Modifier.isStatic(it.modifiers) }.forEach { method -> + putFunction(method.name) { args -> + clazz.declaredMethods.find { + it.name == method.name && it.parameterTypes.zip(args ?: emptyArray()).all { (type, arg) -> + type.isAssignableFrom(arg.javaClass) + } + }?.invoke(null, *args ?: emptyArray()) + } + } + + clazz.declaredFields.filter { Modifier.isStatic(it.modifiers) }.forEach { field -> + field.isAccessible = true + defineProperty(field.name, { field.get(null)}, { value -> field.set(null, value) }, 0) + } + } + } + + moduleObject.putFunction("logInfo") { args -> + scriptRuntime.logger.info(args?.joinToString(" ") { + when (it) { + is Wrapper -> it.unwrap().toString() + else -> it.toString() + } + } ?: "null") + Undefined.instance + } + + for (toastFunc in listOf("longToast", "shortToast")) { + moduleObject.putFunction(toastFunc) { args -> + Handler(scriptRuntime.androidContext.mainLooper).post { + Toast.makeText( + scriptRuntime.androidContext, + args?.joinToString(" ") ?: "", + if (toastFunc == "longToast") Toast.LENGTH_LONG else Toast.LENGTH_SHORT + ).show() + } + Undefined.instance + } + } + + block(moduleObject) + evaluateString(moduleObject, content, moduleInfo.name, 1, null) + } + } + + fun unload() { + callFunction("module.onUnload") + } + + fun callFunction(name: String, vararg args: Any?) { + contextScope { + name.split(".").also { split -> + val function = split.dropLast(1).fold(moduleObject) { obj, key -> + obj.get(key, obj) as? ScriptableObject ?: return@contextScope + }.get(split.last(), moduleObject) as? Function ?: return@contextScope + + function.call(this, moduleObject, moduleObject, args) + } + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/PrimitiveUtil.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/PrimitiveUtil.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.common.scripting + +fun Any?.toPrimitiveValue(type: Lazy<String>) = when (this) { + is Number -> when (type.value) { + "byte" -> this.toByte() + "short" -> this.toShort() + "int" -> this.toInt() + "long" -> this.toLong() + "float" -> this.toFloat() + "double" -> this.toDouble() + "boolean" -> this.toByte() != 0.toByte() + "char" -> this.toInt().toChar() + else -> this + } + is Boolean -> if (type.value == "boolean") this.toString().toBoolean() else this + else -> this +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt @@ -0,0 +1,90 @@ +package me.rhunk.snapenhance.common.scripting + +import android.content.Context +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import org.mozilla.javascript.ScriptableObject +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStream + +open class ScriptRuntime( + val androidContext: Context, + val logger: AbstractLogger, +) { + var buildModuleObject: ScriptableObject.(JSModule) -> Unit = {} + private val modules = mutableMapOf<String, JSModule>() + + fun eachModule(f: JSModule.() -> Unit) { + modules.values.forEach { module -> + runCatching { + module.f() + }.onFailure { + logger.error("Failed to run module function in ${module.moduleInfo.name}", it) + } + } + } + + private fun readModuleInfo(reader: BufferedReader): ModuleInfo { + val header = reader.readLine() + if (!header.startsWith("// ==SE_module==")) { + throw Exception("Invalid module header") + } + + val properties = mutableMapOf<String, String>() + while (true) { + val line = reader.readLine() + if (line.startsWith("// ==/SE_module==")) { + break + } + val split = line.replaceFirst("//", "").split(":") + if (split.size != 2) { + throw Exception("Invalid module property") + } + properties[split[0].trim()] = split[1].trim() + } + + return ModuleInfo( + name = properties["name"] ?: throw Exception("Missing module name"), + version = properties["version"] ?: throw Exception("Missing module version"), + description = properties["description"], + author = properties["author"], + minSnapchatVersion = properties["minSnapchatVersion"]?.toLong(), + minSEVersion = properties["minSEVersion"]?.toLong(), + grantPermissions = properties["permissions"]?.split(",")?.map { it.trim() }, + ) + } + + fun getModuleInfo(inputStream: InputStream): ModuleInfo { + return readModuleInfo(inputStream.bufferedReader()) + } + + fun reload(path: String, content: String) { + unload(path) + load(path, content) + } + + private fun unload(path: String) { + val module = modules[path] ?: return + module.unload() + modules.remove(path) + } + + fun load(path: String, content: String): JSModule? { + logger.info("Loading module $path") + return runCatching { + JSModule( + scriptRuntime = this, + moduleInfo = readModuleInfo(ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)).bufferedReader()), + content = content, + ).apply { + load { + buildModuleObject(this, this@apply) + } + modules[path] = this + } + }.onFailure { + logger.error("Failed to load module $path", it) + }.getOrNull() + } +}+ \ No newline at end of file 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 @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.common.scripting.ktx + +import org.mozilla.javascript.Context +import org.mozilla.javascript.Function +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.ScriptableObject + +fun contextScope(f: Context.() -> Unit) { + val context = Context.enter() + context.optimizationLevel = -1 + try { + context.f() + } finally { + Context.exit() + } +} + +fun Scriptable.scriptable(name: String): Scriptable? { + return this.get(name, this) as? Scriptable +} + +fun Scriptable.function(name: String): Function? { + return this.get(name, this) as? Function +} + +fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any>?) -> Any?) { + this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { + override fun call( + cx: Context?, + scope: Scriptable, + thisObj: Scriptable, + args: Array<out Any>? + ): Any? { + return thisObj.proxy(args) + } + }) +} + +fun scriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { + return object: ScriptableObject() { + override fun getClassName() = name + }.apply(f) +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/type/ModuleInfo.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.common.scripting.type + +data class ModuleInfo( + val name: String, + val version: String, + val description: String? = null, + val author: String? = null, + val minSnapchatVersion: Long? = null, + val minSEVersion: Long? = null, + val grantPermissions: List<String>? = null, +)+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SQLiteDatabaseHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SQLiteDatabaseHelper.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.common.util + +import android.annotation.SuppressLint +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.common.logger.AbstractLogger + +object SQLiteDatabaseHelper { + @SuppressLint("Range") + fun createTablesFromSchema(sqLiteDatabase: SQLiteDatabase, databaseSchema: Map<String, List<String>>) { + databaseSchema.forEach { (tableName, columns) -> + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + + val cursor = sqLiteDatabase.rawQuery("PRAGMA table_info($tableName)", null) + val existingColumns = mutableListOf<String>() + while (cursor.moveToNext()) { + existingColumns.add(cursor.getString(cursor.getColumnIndex("name")) + " " + cursor.getString(cursor.getColumnIndex("type"))) + } + cursor.close() + + val newColumns = columns.filter { + existingColumns.none { existingColumn -> it.startsWith(existingColumn) } + } + + if (newColumns.isEmpty()) return@forEach + + AbstractLogger.directDebug("Schema for table $tableName has changed") + sqLiteDatabase.execSQL("DROP TABLE $tableName") + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SerializableDataObject.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/SerializableDataObject.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.common.util + +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +open class SerializableDataObject { + companion object { + val gson: Gson = GsonBuilder().create() + + inline fun <reified T : SerializableDataObject> fromJson(json: String): T { + return gson.fromJson(json, T::class.java) + } + + inline fun <reified T : SerializableDataObject> fromJson(json: String, type: Class<T>): T { + return gson.fromJson(json, type) + } + } + + fun toJson(): String { + return gson.toJson(this) + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.common.util.ktx + +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ApplicationInfoFlags +import android.os.Build + +fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getApplicationInfo(packageName, ApplicationInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + getApplicationInfo(packageName, flags) + } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/DbCursorExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/DbCursorExt.kt @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.common.util.ktx + +import android.database.Cursor + +fun Cursor.getStringOrNull(columnName: String): String? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getString(columnIndex) +} + +fun Cursor.getIntOrNull(columnName: String): Int? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getInt(columnIndex) +} + +fun Cursor.getInteger(columnName: String) = getIntOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") +fun Cursor.getLong(columnName: String) = getLongOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") + +fun Cursor.getBlobOrNull(columnName: String): ByteArray? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getBlob(columnIndex) +} + + +fun Cursor.getLongOrNull(columnName: String): Long? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getLong(columnIndex) +} + +fun Cursor.getDoubleOrNull(columnName: String): Double? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getDouble(columnIndex) +} + +fun Cursor.getFloatOrNull(columnName: String): Float? { + val columnIndex = getColumnIndex(columnName) + return if (columnIndex == -1) null else getFloat(columnIndex) +}+ \ No newline at end of file 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 @@ -0,0 +1,67 @@ +package me.rhunk.snapenhance.common.util.protobuf + + +typealias WireCallback = EditorContext.() -> Unit + +class EditorContext( + private val wires: MutableMap<Int, MutableList<Wire>> +) { + fun clear() { + wires.clear() + } + fun addWire(wire: Wire) { + wires.getOrPut(wire.id) { mutableListOf() }.add(wire) + } + 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.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)) + fun addFixed32(id: Int, value: Int) = addWire(Wire(id, WireType.FIXED32, value)) + + fun firstOrNull(id: Int) = wires[id]?.firstOrNull() + fun getOrNull(id: Int) = wires[id] + fun get(id: Int) = wires[id]!! + + fun remove(id: Int) = wires.remove(id) + fun remove(id: Int, index: Int) = wires[id]?.removeAt(index) +} + +class ProtoEditor( + private var buffer: ByteArray +) { + fun edit(vararg path: Int, callback: WireCallback) { + buffer = writeAtPath(path, 0, ProtoReader(buffer), callback) + } + + private fun writeAtPath(path: IntArray, currentIndex: Int, rootReader: ProtoReader, wireToWriteCallback: WireCallback): ByteArray { + val id = path.getOrNull(currentIndex) + val output = ProtoWriter() + val wires = sortedMapOf<Int, MutableList<Wire>>() + + rootReader.forEach { wireId, value -> + wires.putIfAbsent(wireId, mutableListOf()) + if (id != null && wireId == id) { + val childReader = rootReader.followPath(id) + if (childReader == null) { + wires.getOrPut(wireId) { mutableListOf() }.add(value) + return@forEach + } + wires[wireId]!!.add(Wire(wireId, WireType.CHUNK, writeAtPath(path, currentIndex + 1, childReader, wireToWriteCallback))) + return@forEach + } + wires[wireId]!!.add(value) + } + + if (currentIndex == path.size) { + wireToWriteCallback(EditorContext(wires)) + } + + wires.values.flatten().forEach(output::addWire) + + return output.toByteArray() + } + + fun toByteArray() = buffer +}+ \ No newline at end of file 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 @@ -0,0 +1,255 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import java.util.UUID + +data class Wire(val id: Int, val type: WireType, val value: Any) + +class ProtoReader(private val buffer: ByteArray) { + private var offset: Int = 0 + private val values = mutableMapOf<Int, MutableList<Wire>>() + + init { + read() + } + + fun getBuffer() = buffer + + private fun readByte() = buffer[offset++] + + private fun readVarInt(): Long { + var result = 0L + var shift = 0 + while (true) { + val b = readByte() + result = result or ((b.toLong() and 0x7F) shl shift) + if (b.toInt() and 0x80 == 0) { + break + } + shift += 7 + } + return result + } + + private fun read() { + while (offset < buffer.size) { + try { + val tag = readVarInt().toInt() + val id = tag ushr 3 + val type = WireType.fromValue(tag and 0x7) ?: break + val value = when (type) { + WireType.VARINT -> readVarInt() + WireType.FIXED64 -> { + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = readByte() + } + bytes + } + WireType.CHUNK -> { + val length = readVarInt().toInt() + val bytes = ByteArray(length) + for (i in 0 until length) { + bytes[i] = readByte() + } + bytes + } + WireType.START_GROUP -> { + val bytes = mutableListOf<Byte>() + while (true) { + val b = readByte() + if (b.toInt() == WireType.END_GROUP.value) { + break + } + bytes.add(b) + } + bytes.toByteArray() + } + WireType.FIXED32 -> { + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = readByte() + } + bytes + } + WireType.END_GROUP -> continue + } + values.getOrPut(id) { mutableListOf() }.add(Wire(id, type, value)) + } catch (t: Throwable) { + values.clear() + break + } + } + } + + fun followPath(vararg ids: Int, excludeLast: Boolean = false, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? { + var thisReader = this + ids.let { + if (excludeLast) { + it.sliceArray(0 until it.size - 1) + } else { + it + } + }.forEach { id -> + if (!thisReader.contains(id)) { + return null + } + thisReader = ProtoReader(thisReader.getByteArray(id) ?: return null) + } + if (reader != null) { + thisReader.reader() + } + return thisReader + } + + fun containsPath(vararg ids: Int): Boolean { + var thisReader = this + ids.forEach { id -> + if (!thisReader.contains(id)) { + return false + } + thisReader = ProtoReader(thisReader.getByteArray(id) ?: return false) + } + return true + } + + fun forEach(reader: (Int, Wire) -> Unit) { + values.forEach { (id, wires) -> + wires.forEach { wire -> + reader(id, wire) + } + } + } + + fun forEach(vararg id: Int, reader: ProtoReader.() -> Unit) { + followPath(*id)?.eachBuffer { _, buffer -> + ProtoReader(buffer).reader() + } + } + + fun eachBuffer(vararg ids: Int, reader: ProtoReader.() -> Unit) { + followPath(*ids, excludeLast = true)?.eachBuffer { id, buffer -> + if (id == ids.last()) { + ProtoReader(buffer).reader() + } + } + } + + fun eachBuffer(reader: (Int, ByteArray) -> Unit) { + values.forEach { (id, wires) -> + wires.forEach { wire -> + if (wire.type == WireType.CHUNK) { + reader(id, wire.value as ByteArray) + } + } + } + } + + fun contains(id: Int) = values.containsKey(id) + + fun getWire(id: Int) = values[id]?.firstOrNull() + fun getRawValue(id: Int) = getWire(id)?.value + fun getByteArray(id: Int) = getRawValue(id) as? ByteArray + fun getByteArray(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getByteArray(ids.last()) + fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8) + fun getString(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getString(ids.last()) + fun getVarInt(id: Int) = getRawValue(id) as? Long + fun getVarInt(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getVarInt(ids.last()) + fun getCount(id: Int) = values[id]?.size ?: 0 + + fun getFixed64(id: Int): Long { + val bytes = getByteArray(id) ?: return 0L + var value = 0L + for (i in 0..7) { + value = value or ((bytes[i].toLong() and 0xFF) shl (i * 8)) + } + return value + } + + + fun getFixed32(id: Int): Int { + val bytes = getByteArray(id) ?: return 0 + var value = 0 + for (i in 0..3) { + value = value or ((bytes[i].toInt() and 0xFF) shl (i * 8)) + } + 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 ((7 - i) * 8)) + } + for (i in 8 .. 15) { + longs[1] = longs[1] or ((array[i].toLong() and 0xFF) shl ((15 - i) * 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/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoWriter.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/ProtoWriter.kt @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.common.util.protobuf + +import java.io.ByteArrayOutputStream + +class ProtoWriter { + private val stream: ByteArrayOutputStream = ByteArrayOutputStream() + + private fun writeVarInt(value: Int) { + var v = value + while (v and -0x80 != 0) { + stream.write(v and 0x7F or 0x80) + v = v ushr 7 + } + stream.write(v) + } + + private fun writeVarLong(value: Long) { + var v = value + while (v and -0x80L != 0L) { + stream.write((v and 0x7FL or 0x80L).toInt()) + v = v ushr 7 + } + stream.write(v.toInt()) + } + + fun addBuffer(id: Int, value: ByteArray) { + writeVarInt(id shl 3 or WireType.CHUNK.value) + writeVarInt(value.size) + stream.write(value) + } + + fun addVarInt(id: Int, value: Int) = addVarInt(id, value.toLong()) + + fun addVarInt(id: Int, value: Long) { + writeVarInt(id shl 3) + writeVarLong(value) + } + + fun addString(id: Int, value: String) = addBuffer(id, value.toByteArray()) + + fun addFixed32(id: Int, value: Int) { + writeVarInt(id shl 3 or WireType.FIXED32.value) + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = (value shr (i * 8)).toByte() + } + stream.write(bytes) + } + + fun addFixed64(id: Int, value: Long) { + writeVarInt(id shl 3 or WireType.FIXED64.value) + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = (value shr (i * 8)).toByte() + } + stream.write(bytes) + } + + fun from(id: Int, writer: ProtoWriter.() -> Unit) { + val writerStream = ProtoWriter() + writer(writerStream) + addBuffer(id, writerStream.stream.toByteArray()) + } + + fun from(vararg ids: Int, writer: ProtoWriter.() -> Unit) { + val writerStream = ProtoWriter() + writer(writerStream) + var stream = writerStream.stream.toByteArray() + ids.reversed().forEach { id -> + with(ProtoWriter()) { + addBuffer(id, stream) + stream = this.stream.toByteArray() + } + } + stream.let(this.stream::write) + } + + fun addWire(wire: Wire) { + writeVarInt(wire.id shl 3 or wire.type.value) + when (wire.type) { + WireType.VARINT -> writeVarLong(wire.value as Long) + WireType.FIXED64, WireType.FIXED32 -> { + when (wire.value) { + is Int -> { + val bytes = ByteArray(4) + for (i in 0..3) { + bytes[i] = (wire.value shr (i * 8)).toByte() + } + stream.write(bytes) + } + is Long -> { + val bytes = ByteArray(8) + for (i in 0..7) { + bytes[i] = (wire.value shr (i * 8)).toByte() + } + stream.write(bytes) + } + is ByteArray -> stream.write(wire.value) + } + } + WireType.CHUNK -> { + val value = wire.value as ByteArray + writeVarInt(value.size) + stream.write(value) + } + WireType.START_GROUP -> { + val value = wire.value as ByteArray + stream.write(value) + } + WireType.END_GROUP -> return + } + } + + fun toByteArray(): ByteArray { + return stream.toByteArray() + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/WireType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/protobuf/WireType.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.common.util.protobuf; + +enum class WireType(val value: Int) { + VARINT(0), + FIXED64(1), + CHUNK(2), + START_GROUP(3), + END_GROUP(4), + FIXED32(5); + + companion object { + fun fromValue(value: Int) = entries.firstOrNull { it.value == value } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.common.util.snap + +object BitmojiSelfie { + 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? { + if (selfieId.isNullOrEmpty() || avatarId.isNullOrEmpty()) { + return null + } + return when (type) { + 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 diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.common.util.snap + +import me.rhunk.snapenhance.common.data.FileType +import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType +import java.io.BufferedInputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + + +object MediaDownloaderHelper { + fun getFileType(bufferedInputStream: BufferedInputStream): FileType { + val buffer = ByteArray(16) + bufferedInputStream.mark(16) + bufferedInputStream.read(buffer) + bufferedInputStream.reset() + return FileType.fromByteArray(buffer) + } + + + fun getSplitElements( + inputStream: InputStream, + callback: (SplitMediaAssetType, InputStream) -> Unit + ) { + val bufferedInputStream = BufferedInputStream(inputStream) + val fileType = getFileType(bufferedInputStream) + + if (fileType != FileType.ZIP) { + callback(SplitMediaAssetType.ORIGINAL, bufferedInputStream) + return + } + + val zipInputStream = ZipInputStream(bufferedInputStream) + + var entry: ZipEntry? = zipInputStream.nextEntry + while (entry != null) { + if (entry.name.startsWith("overlay")) { + callback(SplitMediaAssetType.OVERLAY, zipInputStream) + } else if (entry.name.startsWith("media")) { + callback(SplitMediaAssetType.ORIGINAL, zipInputStream) + } + entry = zipInputStream.nextEntry + } + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/RemoteMediaResolver.kt @@ -0,0 +1,68 @@ +package me.rhunk.snapenhance.common.util.snap + +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.logger.AbstractLogger +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.InputStream +import java.util.Base64 + +object RemoteMediaResolver { + 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<String, String>() + + private val okHttpClient = OkHttpClient.Builder() + .followRedirects(true) + .retryOnConnectionFailure(true) + .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS) + .addInterceptor { chain -> + val request = chain.request() + val requestUrl = request.url.toString() + + if (urlCache.containsKey(requestUrl)) { + val cachedUrl = urlCache[requestUrl]!! + return@addInterceptor chain.proceed(request.newBuilder().url(cachedUrl).build()) + } + + chain.proceed(request).apply { + val responseUrl = this.request.url.toString() + if (responseUrl.startsWith("https://cf-st.sc-cdn.net")) { + urlCache[requestUrl] = responseUrl + } + } + } + .build() + + private fun newResolveRequest(protoKey: ByteArray) = Request.Builder() + .url(BOLT_HTTP_RESOLVER_URL + "/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey)) + .addHeader("User-Agent", Constants.USER_AGENT) + .build() + + /** + * Download bolt media with memory allocation + */ + fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }): ByteArray? { + okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response -> + if (!response.isSuccessful) { + AbstractLogger.directDebug("Unexpected code $response") + return null + } + return decryptionCallback(response.body.byteStream()).readBytes() + } + } + + fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }, resultCallback: (InputStream) -> Unit) { + okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response -> + if (!response.isSuccessful) { + throw Throwable("invalid response ${response.code}") + } + resultCallback( + decryptionCallback( + response.body.byteStream() + ) + ) + } + } +} diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/SnapWidgetBroadcastReceiverHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/SnapWidgetBroadcastReceiverHelper.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.common.util.snap + +import android.content.Intent +import me.rhunk.snapenhance.common.Constants + +object SnapWidgetBroadcastReceiverHelper { + private const val ACTION_WIDGET_UPDATE = "com.snap.android.WIDGET_APP_START_UPDATE_ACTION" + const val CLASS_NAME = "com.snap.widgets.core.BestFriendsWidgetProvider" + + fun create(targetAction: String, callback: Intent.() -> Unit): Intent { + with(Intent()) { + callback(this) + action = ACTION_WIDGET_UPDATE + putExtra(":)", true) + putExtra("action", targetAction) + setClassName(Constants.SNAPCHAT_PACKAGE_NAME, CLASS_NAME) + return this + } + } + + fun isIncomingIntentValid(intent: Intent): Boolean { + return intent.action == ACTION_WIDGET_UPDATE && intent.getBooleanExtra(":)", false) + } +}+ \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts @@ -7,17 +7,8 @@ android { namespace = rootProject.ext["applicationId"].toString() + ".core" compileSdk = 34 - buildFeatures { - aidl = true - buildConfig = true - } - defaultConfig { minSdk = 28 - buildConfigField("String", "VERSION_NAME", "\"${rootProject.ext["appVersionName"]}\"") - buildConfigField("int", "VERSION_CODE", "${rootProject.ext["appVersionCode"]}") - buildConfigField("String", "APPLICATION_ID", "\"${rootProject.ext["applicationId"]}\"") - buildConfigField("int", "BUILD_DATE", "${System.currentTimeMillis() / 1000}") } kotlinOptions { @@ -25,13 +16,6 @@ android { } } -tasks.register("getVersion") { - doLast { - val versionFile = File("app/build/version.txt") - versionFile.writeText(rootProject.ext["appVersionName"].toString()) - } -} - dependencies { compileOnly(files("libs/LSPosed-api-1.0-SNAPSHOT.jar")) implementation(libs.coroutines) @@ -41,6 +25,7 @@ dependencies { implementation(libs.androidx.documentfile) implementation(libs.rhino) + implementation(project(":common")) implementation(project(":stub")) implementation(project(":mapper")) implementation(project(":native")) diff --git a/core/src/main/assets/xposed_init b/core/src/main/assets/xposed_init @@ -1 +1 @@ -me.rhunk.snapenhance.XposedLoader- \ No newline at end of file +me.rhunk.snapenhance.core.XposedLoader+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/core/src/main/kotlin/me/rhunk/snapenhance/Constants.kt @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance - -object Constants { - const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" - - val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) - - 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/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -1,155 +0,0 @@ -package me.rhunk.snapenhance - -import android.app.Activity -import android.content.ClipData -import android.content.Context -import android.content.Intent -import android.content.res.Resources -import android.os.Handler -import android.os.Looper -import android.os.Process -import android.widget.Toast -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -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.event.EventBus -import me.rhunk.snapenhance.core.event.EventDispatcher -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.scripting.core.CoreScriptRuntime -import kotlin.reflect.KClass -import kotlin.system.exitProcess - -class ModContext { - val coroutineScope = CoroutineScope(Dispatchers.IO) - - lateinit var androidContext: Context - lateinit var bridgeClient: BridgeClient - var mainActivity: Activity? = null - - val classCache get() = SnapEnhance.classCache - val resources: Resources get() = androidContext.resources - val gson: Gson = GsonBuilder().create() - - private val _config = ModConfig() - val config by _config::root - val log by lazy { Logger(this.bridgeClient) } - val translation = LocaleWrapper() - val httpServer = HttpServer() - val messageSender = MessageSender(this) - - val features = FeatureManager(this) - val mappings = MappingsWrapper() - val actionManager = ActionManager(this) - val database = DatabaseAccess(this) - val event = EventBus(this) - val eventDispatcher = EventDispatcher(this) - val native = NativeLib() - val scriptRuntime by lazy { CoreScriptRuntime(androidContext, log) } - - val isDeveloper by lazy { config.scripting.developerMode.get() } - - fun <T : Feature> feature(featureClass: KClass<T>): T { - return features.get(featureClass)!! - } - - fun runOnUiThread(runnable: () -> Unit) { - if (Looper.myLooper() == Looper.getMainLooper()) { - runnable() - return - } - Handler(Looper.getMainLooper()).post { - runCatching(runnable).onFailure { - Logger.xposedLog("UI thread runnable failed", it) - } - } - } - - fun executeAsync(runnable: suspend ModContext.() -> Unit) { - coroutineScope.launch { - runCatching { - runnable() - }.onFailure { - longToast("Async task failed " + it.message) - log.error("Async task failed", it) - } - } - } - - fun shortToast(message: Any?) { - runOnUiThread { - Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() - } - } - - fun longToast(message: Any?) { - runOnUiThread { - Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() - } - } - - fun softRestartApp(saveSettings: Boolean = false) { - if (saveSettings) { - _config.writeConfig() - } - val intent: Intent? = androidContext.packageManager.getLaunchIntentForPackage( - Constants.SNAPCHAT_PACKAGE_NAME - ) - intent?.let { - val mainIntent = Intent.makeRestartActivityTask(intent.component) - androidContext.startActivity(mainIntent) - } - exitProcess(1) - } - - fun crash(message: String, throwable: Throwable? = null) { - logCritical(message, throwable ?: Throwable()) - delayForceCloseApp(100) - } - - fun logCritical(message: Any?, throwable: Throwable = Throwable()) { - log.error(message ?: "Snapchat crash", throwable) - longToast(message ?: "Snapchat has crashed! Please check logs for more details.") - } - - private fun delayForceCloseApp(delay: Long) = Handler(Looper.getMainLooper()).postDelayed({ - forceCloseApp() - }, delay) - - fun forceCloseApp() { - Process.killProcess(Process.myPid()) - exitProcess(1) - } - - fun reloadConfig() { - log.verbose("reloading config") - _config.loadFromBridge(bridgeClient) - native.loadNativeConfig( - NativeConfig( - disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), - disableMetrics = config.global.disableMetrics.get() - ) - ) - } - - fun getConfigLocale(): String { - return _config.locale - } - - fun copyToClipboard(data: String, label: String = "Copied Text") { - androidContext.getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText(label, data)) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt @@ -1,253 +0,0 @@ -package me.rhunk.snapenhance - -import android.app.Activity -import android.app.Application -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.action.EnumAction -import me.rhunk.snapenhance.bridge.ConfigStateListener -import me.rhunk.snapenhance.bridge.SyncCallback -import me.rhunk.snapenhance.core.bridge.BridgeClient -import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent -import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent -import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo -import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo -import me.rhunk.snapenhance.data.SnapClassCache -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook -import kotlin.system.measureTimeMillis - - -class SnapEnhance { - companion object { - lateinit var classLoader: ClassLoader - private set - val classCache by lazy { - SnapClassCache(classLoader) - } - } - private val 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 -> - val activity = param.thisObject() as Activity - if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook - block(activity) - } - } - - init { - Application::class.java.hook("attach", HookStage.BEFORE) { param -> - appContext.apply { - androidContext = param.arg<Context>(0).also { - classLoader = it.classLoader - } - bridgeClient = BridgeClient(appContext) - bridgeClient.apply { - connect( - timeout = { - crash("SnapEnhance bridge service is not responding. Please download stable version from https://github.com/rhunk/SnapEnhance/releases", it) - } - ) { bridgeResult -> - if (!bridgeResult) { - logCritical("Cannot connect to bridge service") - softRestartApp() - return@connect - } - runCatching { - measureTimeMillis { - runBlocking { - init(this) - } - }.also { - appContext.log.verbose("init took ${it}ms") - } - }.onSuccess { - isBridgeInitialized = true - }.onFailure { - logCritical("Failed to initialize bridge", it) - } - } - } - } - } - - hookMainActivity("onCreate") { - val isMainActivityNotNull = appContext.mainActivity != null - appContext.mainActivity = this - if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hookMainActivity - onActivityCreate() - } - - hookMainActivity("onPause") { - appContext.bridgeClient.closeSettingsOverlay() - isActivityPaused = 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 - if (!activityWasResumed) { - activityWasResumed = true - return@hookMainActivity - } - - appContext.actionManager.onNewIntent(this.intent) - appContext.reloadConfig() - syncRemote() - } - } - - private fun init(scope: CoroutineScope) { - with(appContext) { - Thread::class.java.hook("dispatchUncaughtException", HookStage.BEFORE) { param -> - runCatching { - val throwable = param.argNullable(0) ?: Throwable() - logCritical(null, throwable) - } - } - - reloadConfig() - actionManager.init() - initConfigListener() - scope.launch(Dispatchers.IO) { - initNative() - translation.userLocale = getConfigLocale() - translation.loadFromBridge(bridgeClient) - } - - mappings.loadFromBridge(bridgeClient) - mappings.init(androidContext) - eventDispatcher.init() - //if mappings aren't loaded, we can't initialize features - if (!mappings.isMappingsLoaded()) return - features.init() - scriptRuntime.connect(bridgeClient.getScriptingInterface()) - syncRemote() - } - } - - private fun onActivityCreate() { - measureTimeMillis { - with(appContext) { - features.onActivityCreate() - scriptRuntime.eachModule { callFunction("module.onSnapActivity", mainActivity!!) } - } - }.also { time -> - appContext.log.verbose("onActivityCreate took $time") - } - } - - private fun initNative() { - // don't initialize native when not logged in - if (!appContext.database.hasArroyo()) return - appContext.native.apply { - if (appContext.config.experimental.nativeHooks.globalState != true) return@apply - initOnce(appContext.androidContext.classLoader) - nativeUnaryCallCallback = { request -> - appContext.event.post(UnaryCallEvent(request.uri, request.buffer)) { - request.buffer = buffer - request.canceled = canceled - } - } - } - } - - private fun initConfigListener() { - val tasks = linkedSetOf<() -> Unit>() - hookMainActivity("onResume") { - tasks.forEach { it() } - } - - fun runLater(task: () -> Unit) { - if (isActivityPaused) { - tasks.add(task) - } else { - task() - } - } - - appContext.apply { - bridgeClient.registerConfigStateListener(object: ConfigStateListener.Stub() { - override fun onConfigChanged() { - log.verbose("onConfigChanged") - reloadConfig() - } - - override fun onRestartRequired() { - log.verbose("onRestartRequired") - runLater { - log.verbose("softRestart") - softRestartApp(saveSettings = false) - } - } - - override fun onCleanCacheRequired() { - log.verbose("onCleanCacheRequired") - tasks.clear() - runLater { - log.verbose("cleanCache") - actionManager.execute(EnumAction.CLEAN_CACHE) - } - } - }) - } - - } - - private fun syncRemote() { - appContext.executeAsync { - bridgeClient.sync(object : SyncCallback.Stub() { - override fun syncFriend(uuid: String): String? { - return database.getFriendInfo(uuid)?.toJson() - } - - override fun syncGroup(uuid: String): String? { - return database.getFeedEntryByConversationId(uuid)?.let { - MessagingGroupInfo( - it.key!!, - it.feedDisplayName!!, - it.participantsSize - ).toJson() - } - } - }) - - event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> - if (event.action != BridgeClient.BRIDGE_SYNC_ACTION) return@subscribe - event.canceled = true - val feedEntries = appContext.database.getFeedEntries(Int.MAX_VALUE) - - val groups = feedEntries.filter { it.friendUserId == null }.map { - MessagingGroupInfo( - it.key!!, - it.feedDisplayName!!, - it.participantsSize - ) - } - - val friends = feedEntries.filter { it.friendUserId != null }.map { - MessagingFriendInfo( - it.friendUserId!!, - it.friendDisplayName, - it.friendDisplayUsername!!.split("|")[1], - it.bitmojiAvatarId, - it.bitmojiSelfieId - ) - } - - bridgeClient.passGroupsAndFriends( - groups.map { it.toJson() }, - friends.map { it.toJson() } - ) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/XposedLoader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/XposedLoader.kt @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance - -import de.robv.android.xposed.IXposedHookLoadPackage -import de.robv.android.xposed.callbacks.XC_LoadPackage - -class XposedLoader : IXposedHookLoadPackage { - override fun handleLoadPackage(p0: XC_LoadPackage.LoadPackageParam) { - if (p0.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return - SnapEnhance() - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/AbstractAction.kt @@ -1,23 +0,0 @@ -package me.rhunk.snapenhance.action - -import me.rhunk.snapenhance.ModContext -import java.io.File - -abstract class AbstractAction{ - lateinit var context: ModContext - - /** - * called when the action is triggered - */ - open fun run() {} - - protected open fun deleteRecursively(parent: File?) { - if (parent == null) return - if (parent.isDirectory) for (child in parent.listFiles()!!) deleteRecursively( - child - ) - if (parent.exists() && (parent.isFile || parent.isDirectory)) { - parent.delete() - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/EnumAction.kt @@ -1,17 +0,0 @@ -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<out AbstractAction>, - 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/CleanCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/CleanCache.kt @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import me.rhunk.snapenhance.action.AbstractAction -import java.io.File - -class CleanCache : AbstractAction() { - companion object { - private val FILES = arrayOf( - "files/mbgl-offline.db", - "files/native_content_manager/*", - "files/file_manager/*", - "files/blizzardv2/*", - "files/streaming/*", - "cache/*", - "databases/media_packages", - "databases/simple_db_helper.db", - "databases/journal.db", - "databases/arroyo.db", - "databases/arroyo.db-wal", - "databases/native_content_manager/*" - ) - } - - override fun run() { - FILES.forEach { fileName -> - val fileCache = File(context.androidContext.dataDir, fileName) - if (fileName.endsWith("*")) { - val parent = fileCache.parentFile ?: throw IllegalStateException("Parent file is null") - if (parent.exists()) { - parent.listFiles()?.forEach(this::deleteRecursively) - } - return@forEach - } - if (fileCache.exists()) { - deleteRecursively(fileCache) - } - } - - context.softRestartApp() - } -}- \ 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 @@ -1,313 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import android.app.AlertDialog -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 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 java.io.File -import kotlin.math.absoluteValue - -class ExportChatMessages : AbstractAction() { - private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } - - private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } - - private val enterConversationMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "enterConversation" } - } - private val exitConversationMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "exitConversation" } - } - private val fetchConversationWithMessagesPaginatedMethod by lazy { - context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } - } - - private val conversationManagerInstance by lazy { - context.feature(Messaging::class).conversationManager - } - - private val dialogLogs = mutableListOf<String>() - private var currentActionDialog: AlertDialog? = null - - private var exportType: ExportFormat? = null - private var mediaToDownload: List<ContentType>? = null - private var amountOfMessages: Int? = null - - private fun logDialog(message: String) { - context.runOnUiThread { - if (dialogLogs.size > 10) dialogLogs.removeAt(0) - dialogLogs.add(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) - .setTitle(context.translation["chat_export.select_export_format"]) - .setItems(ExportFormat.entries.map { it.name }.toTypedArray()) { _, which -> - cont.resumeWith(Result.success(ExportFormat.entries[which])) - } - .setOnCancelListener { - cont.resumeWith(Result.success(null)) - } - .show() - } - } - - private suspend fun askAmountOfMessages() = suspendCancellableCoroutine { cont -> - context.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<ContentType>() - val contentTypes = arrayOf( - ContentType.SNAP, - ContentType.EXTERNAL_MEDIA, - ContentType.NOTE, - ContentType.STICKER - ) - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(context.translation["chat_export.select_media_type"]) - .setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked -> - val media = contentTypes[which] - if (isChecked) { - mediasToDownload.add(media) - } else if (mediasToDownload.contains(media)) { - mediasToDownload.remove(media) - } - } - .setOnCancelListener { - cont.resumeWith(Result.success(null)) - } - .setPositiveButton(context.translation["button.ok"]) { _, _ -> - cont.resumeWith(Result.success(mediasToDownload)) - } - .show() - } - } - - override fun run() { - context.coroutineScope.launch(Dispatchers.Main) { - exportType = askExportType() ?: return@launch - mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null - amountOfMessages = askAmountOfMessages() - - val friendFeedEntries = context.database.getFeedEntries(500) - val selectedConversations = mutableListOf<FriendFeedEntry>() - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(context.translation["chat_export.select_conversation"]) - .setMultiChoiceItems( - friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayUsername!!.split("|").firstOrNull() }.toTypedArray(), - BooleanArray(friendFeedEntries.size) { false } - ) { _, which, isChecked -> - if (isChecked) { - selectedConversations.add(friendFeedEntries[which]) - } else if (selectedConversations.contains(friendFeedEntries[which])) { - selectedConversations.remove(friendFeedEntries[which]) - } - } - .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> - dialog.dismiss() - } - .setNeutralButton(context.translation["chat_export.dialog_neutral_button"]) { _, _ -> - exportChatForConversations(friendFeedEntries) - } - .setPositiveButton(context.translation["chat_export.dialog_positive_button"]) { _, _ -> - exportChatForConversations(selectedConversations) - } - .show() - } - } - - private suspend fun conversationAction(isEntering: Boolean, conversationId: String, conversationType: String?) = suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder(callbackClass) - .override("onSuccess") { _ -> - continuation.resumeWith(Result.success(Unit)) - } - .override("onError") { - continuation.resumeWith(Result.failure(Exception("Failed to ${if (isEntering) "enter" else "exit"} conversation"))) - }.build() - - if (isEntering) { - enterConversationMethod.invoke( - conversationManagerInstance, - SnapUUID.fromString(conversationId).instanceNonNull(), - enterConversationMethod.parameterTypes[1].enumConstants.first { it.toString() == conversationType }, - callback - ) - } else { - exitConversationMethod.invoke( - conversationManagerInstance, - SnapUUID.fromString(conversationId).instanceNonNull(), - Long.MAX_VALUE, - callback - ) - } - } - - private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long) = suspendCancellableCoroutine { continuation -> - val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass) - .override("onFetchConversationWithMessagesComplete") { param -> - val messagesList = param.arg<List<*>>(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( - conversationManagerInstance, - SnapUUID.fromString(conversationId).instanceNonNull(), - lastMessageId, - 500, - callback - ) - } - - private suspend fun exportFullConversation(friendFeedEntry: FriendFeedEntry) { - //first fetch the first message - val conversationId = friendFeedEntry.key!! - val conversationName = friendFeedEntry.feedDisplayName ?: friendFeedEntry.friendDisplayName!!.split("|").lastOrNull() ?: "unknown" - - 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() - var lastMessageId = foundMessages.firstOrNull()?.messageDescriptor?.messageId ?: run { - logDialog(context.translation["chat_export.no_messages_found"]) - return - } - - 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( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "SnapEnhance/conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}" - ).also { it.parentFile?.mkdirs() } - - 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() - ) + "\n") - - runCatching { - conversationAction(false, conversationId, null) - } - } - - private fun exportChatForConversations(conversations: List<FriendFeedEntry>) { - dialogLogs.clear() - val jobs = mutableListOf<Job>() - - currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(context.translation["chat_export.exporting_chats"]) - .setCancelable(false) - .setMessage("") - .create() - - val conversationSize = context.translation.format("chat_export.processing_chats", "amount" to conversations.size.toString()) - - logDialog(conversationSize) - - context.coroutineScope.launch { - conversations.forEach { conversation -> - launch { - runCatching { - exportFullConversation(conversation) - }.onFailure { - logDialog(context.translation.format("chat_export.export_fail", "conversation" to conversation.key.toString())) - logDialog(it.stackTraceToString()) - Logger.xposedLog(it) - } - }.also { jobs.add(it) } - } - jobs.joinAll() - logDialog(context.translation["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/action/impl/OpenMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.action.impl - -import android.content.Intent -import android.os.Bundle -import me.rhunk.snapenhance.action.AbstractAction -import me.rhunk.snapenhance.core.BuildConfig - -class OpenMap: AbstractAction() { - override fun run() { - context.runOnUiThread { - val mapActivityIntent = Intent() - mapActivityIntent.setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".ui.MapActivity") - mapActivityIntent.putExtra("location", Bundle().apply { - putDouble("latitude", context.config.experimental.spoof.location.latitude.get().toDouble()) - putDouble("longitude", context.config.experimental.spoof.location.longitude.get().toDouble()) - }) - - context.mainActivity!!.startActivityForResult(mapActivityIntent, 0x1337) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt @@ -0,0 +1,70 @@ +package me.rhunk.snapenhance.core + +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType + +class DownloadManagerClient ( + private val context: ModContext, + private val metadata: DownloadMetadata, + private val callback: DownloadCallback +) { + private fun enqueueDownloadRequest(request: DownloadRequest) { + context.bridgeClient.enqueueDownload(Intent().apply { + putExtras(Bundle().apply { + putString(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request)) + putString(ReceiversConfig.DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata)) + }) + }, callback) + } + + fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = playlistUrl, + type = DownloadMediaType.REMOTE_MEDIA + ) + ), + dashOptions = DashOptions(offsetTime, duration), + flags = DownloadRequest.Flags.IS_DASH_PLAYLIST + ) + ) + } + + fun downloadSingleMedia( + mediaData: String, + mediaType: DownloadMediaType, + encryption: MediaEncryptionKeyPair? = null, + attachmentType: AttachmentType? = null + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = mediaData, + type = mediaType, + encryption = encryption, + attachmentType = attachmentType?.name + ) + ) + ) + ) + } + + fun downloadMediaWithOverlay( + original: InputMedia, + overlay: InputMedia, + ) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = arrayOf(original, overlay), + flags = DownloadRequest.Flags.MERGE_OVERLAY + ) + ) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/Logger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/Logger.kt @@ -1,86 +0,0 @@ -package me.rhunk.snapenhance.core - -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.core.logger.AbstractLogger -import me.rhunk.snapenhance.core.logger.LogChannel -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.core.logger.LogLevel - - -@SuppressLint("PrivateApi") -class Logger( - private val bridgeClient: BridgeClient -): AbstractLogger(LogChannel.CORE) { - companion object { - private const val TAG = "SnapEnhanceCore" - - fun directDebug(message: Any?, tag: String = TAG) { - Log.println(Log.DEBUG, tag, message.toString()) - } - - 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?, throwable: Throwable, tag: String = TAG) { - Log.println(Log.INFO, tag, message.toString()) - XposedBridge.log("$tag: $message") - XposedBridge.log(throwable) - } - } - - 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 { - invokeOriginalPrintLog(logLevel.priority, tag, message.toString()) - } - } - - override fun debug(message: Any?, tag: String) = internalLog(tag, LogLevel.DEBUG, message) - - override fun error(message: Any?, tag: String) = internalLog(tag, LogLevel.ERROR, message) - - override fun error(message: Any?, throwable: Throwable, tag: String) { - internalLog(tag, LogLevel.ERROR, message) - internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) - } - - override fun info(message: Any?, tag: String) = internalLog(tag, LogLevel.INFO, message) - - override fun verbose(message: Any?, tag: String) = internalLog(tag, LogLevel.VERBOSE, message) - - override fun warn(message: Any?, tag: String) = internalLog(tag, LogLevel.WARN, message) - - override fun assert(message: Any?, tag: String) = internalLog(tag, LogLevel.ASSERT, message) -}- \ 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 @@ -0,0 +1,159 @@ +package me.rhunk.snapenhance.core + +import android.app.Activity +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.widget.Toast +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper +import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper +import me.rhunk.snapenhance.common.config.ModConfig +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.bridge.loadFromBridge +import me.rhunk.snapenhance.core.database.DatabaseAccess +import me.rhunk.snapenhance.core.event.EventBus +import me.rhunk.snapenhance.core.event.EventDispatcher +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.manager.impl.ActionManager +import me.rhunk.snapenhance.core.manager.impl.FeatureManager +import me.rhunk.snapenhance.core.messaging.MessageSender +import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime +import me.rhunk.snapenhance.core.util.media.HttpServer +import me.rhunk.snapenhance.nativelib.NativeConfig +import me.rhunk.snapenhance.nativelib.NativeLib +import kotlin.reflect.KClass +import kotlin.system.exitProcess + +class ModContext { + val coroutineScope = CoroutineScope(Dispatchers.IO) + + lateinit var androidContext: Context + lateinit var bridgeClient: BridgeClient + var mainActivity: Activity? = null + + val classCache get() = SnapEnhance.classCache + val resources: Resources get() = androidContext.resources + val gson: Gson = GsonBuilder().create() + + private val _config = ModConfig() + val config by _config::root + val log by lazy { CoreLogger(this.bridgeClient) } + val translation = LocaleWrapper() + val httpServer = HttpServer() + val messageSender = MessageSender(this) + + val features = FeatureManager(this) + val mappings = MappingsWrapper() + val actionManager = ActionManager(this) + val database = DatabaseAccess(this) + val event = EventBus(this) + val eventDispatcher = EventDispatcher(this) + val native = NativeLib() + val scriptRuntime by lazy { CoreScriptRuntime(androidContext, log) } + + val isDeveloper by lazy { config.scripting.developerMode.get() } + + fun <T : Feature> feature(featureClass: KClass<T>): T { + return features.get(featureClass)!! + } + + fun runOnUiThread(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + runnable() + return + } + Handler(Looper.getMainLooper()).post { + runCatching(runnable).onFailure { + CoreLogger.xposedLog("UI thread runnable failed", it) + } + } + } + + fun executeAsync(runnable: suspend ModContext.() -> Unit) { + coroutineScope.launch { + runCatching { + runnable() + }.onFailure { + longToast("Async task failed " + it.message) + log.error("Async task failed", it) + } + } + } + + fun shortToast(message: Any?) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() + } + } + + fun longToast(message: Any?) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() + } + } + + fun softRestartApp(saveSettings: Boolean = false) { + if (saveSettings) { + _config.writeConfig() + } + val intent: Intent? = androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + ) + intent?.let { + val mainIntent = Intent.makeRestartActivityTask(intent.component) + androidContext.startActivity(mainIntent) + } + exitProcess(1) + } + + fun crash(message: String, throwable: Throwable? = null) { + logCritical(message, throwable ?: Throwable()) + delayForceCloseApp(100) + } + + fun logCritical(message: Any?, throwable: Throwable = Throwable()) { + log.error(message ?: "Snapchat crash", throwable) + longToast(message ?: "Snapchat has crashed! Please check logs for more details.") + } + + private fun delayForceCloseApp(delay: Long) = Handler(Looper.getMainLooper()).postDelayed({ + forceCloseApp() + }, delay) + + fun forceCloseApp() { + Process.killProcess(Process.myPid()) + exitProcess(1) + } + + fun reloadConfig() { + log.verbose("reloading config") + _config.loadFromCallback { file -> + file.loadFromBridge(bridgeClient) + } + native.loadNativeConfig( + NativeConfig( + disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(), + disableMetrics = config.global.disableMetrics.get() + ) + ) + } + + fun getConfigLocale(): String { + return _config.locale + } + + fun copyToClipboard(data: String, label: String = "Copied Text") { + androidContext.getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText(label, data)) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -0,0 +1,258 @@ +package me.rhunk.snapenhance.core + +import android.app.Activity +import android.app.Application +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.bridge.ConfigStateListener +import me.rhunk.snapenhance.bridge.SyncCallback +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.ReceiversConfig +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.bridge.loadFromBridge +import me.rhunk.snapenhance.core.data.SnapClassCache +import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import kotlin.system.measureTimeMillis + + +class SnapEnhance { + companion object { + lateinit var classLoader: ClassLoader + private set + val classCache by lazy { + SnapClassCache(classLoader) + } + } + private val 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 -> + val activity = param.thisObject() as Activity + if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook + block(activity) + } + } + + init { + Application::class.java.hook("attach", HookStage.BEFORE) { param -> + appContext.apply { + androidContext = param.arg<Context>(0).also { + classLoader = it.classLoader + } + bridgeClient = BridgeClient(appContext) + bridgeClient.apply { + connect( + timeout = { + crash("SnapEnhance bridge service is not responding. Please download stable version from https://github.com/rhunk/SnapEnhance/releases", it) + } + ) { bridgeResult -> + if (!bridgeResult) { + logCritical("Cannot connect to bridge service") + softRestartApp() + return@connect + } + runCatching { + measureTimeMillis { + runBlocking { + init(this) + } + }.also { + appContext.log.verbose("init took ${it}ms") + } + }.onSuccess { + isBridgeInitialized = true + }.onFailure { + logCritical("Failed to initialize bridge", it) + } + } + } + } + } + + hookMainActivity("onCreate") { + val isMainActivityNotNull = appContext.mainActivity != null + appContext.mainActivity = this + if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hookMainActivity + onActivityCreate() + } + + hookMainActivity("onPause") { + appContext.bridgeClient.closeSettingsOverlay() + isActivityPaused = 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 + if (!activityWasResumed) { + activityWasResumed = true + return@hookMainActivity + } + + appContext.actionManager.onNewIntent(this.intent) + appContext.reloadConfig() + syncRemote() + } + } + + private fun init(scope: CoroutineScope) { + with(appContext) { + Thread::class.java.hook("dispatchUncaughtException", HookStage.BEFORE) { param -> + runCatching { + val throwable = param.argNullable(0) ?: Throwable() + logCritical(null, throwable) + } + } + + reloadConfig() + actionManager.init() + initConfigListener() + scope.launch(Dispatchers.IO) { + initNative() + translation.userLocale = getConfigLocale() + translation.loadFromCallback { locale -> + bridgeClient.fetchLocales(locale) + } + } + + mappings.loadFromBridge(bridgeClient) + mappings.init(androidContext) + eventDispatcher.init() + //if mappings aren't loaded, we can't initialize features + if (!mappings.isMappingsLoaded()) return + features.init() + scriptRuntime.connect(bridgeClient.getScriptingInterface()) + syncRemote() + } + } + + private fun onActivityCreate() { + measureTimeMillis { + with(appContext) { + features.onActivityCreate() + scriptRuntime.eachModule { callFunction("module.onSnapActivity", mainActivity!!) } + } + }.also { time -> + appContext.log.verbose("onActivityCreate took $time") + } + } + + private fun initNative() { + // don't initialize native when not logged in + if (!appContext.database.hasArroyo()) return + appContext.native.apply { + if (appContext.config.experimental.nativeHooks.globalState != true) return@apply + initOnce(appContext.androidContext.classLoader) + nativeUnaryCallCallback = { request -> + appContext.event.post(UnaryCallEvent(request.uri, request.buffer)) { + request.buffer = buffer + request.canceled = canceled + } + } + } + } + + private fun initConfigListener() { + val tasks = linkedSetOf<() -> Unit>() + hookMainActivity("onResume") { + tasks.forEach { it() } + } + + fun runLater(task: () -> Unit) { + if (isActivityPaused) { + tasks.add(task) + } else { + task() + } + } + + appContext.apply { + bridgeClient.registerConfigStateListener(object: ConfigStateListener.Stub() { + override fun onConfigChanged() { + log.verbose("onConfigChanged") + reloadConfig() + } + + override fun onRestartRequired() { + log.verbose("onRestartRequired") + runLater { + log.verbose("softRestart") + softRestartApp(saveSettings = false) + } + } + + override fun onCleanCacheRequired() { + log.verbose("onCleanCacheRequired") + tasks.clear() + runLater { + log.verbose("cleanCache") + actionManager.execute(EnumAction.CLEAN_CACHE) + } + } + }) + } + + } + + private fun syncRemote() { + appContext.executeAsync { + bridgeClient.sync(object : SyncCallback.Stub() { + override fun syncFriend(uuid: String): String? { + return database.getFriendInfo(uuid)?.toJson() + } + + override fun syncGroup(uuid: String): String? { + return database.getFeedEntryByConversationId(uuid)?.let { + MessagingGroupInfo( + it.key!!, + it.feedDisplayName!!, + it.participantsSize + ).toJson() + } + } + }) + + event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> + if (event.action != ReceiversConfig.BRIDGE_SYNC_ACTION) return@subscribe + event.canceled = true + val feedEntries = appContext.database.getFeedEntries(Int.MAX_VALUE) + + val groups = feedEntries.filter { it.friendUserId == null }.map { + MessagingGroupInfo( + it.key!!, + it.feedDisplayName!!, + it.participantsSize + ) + } + + val friends = feedEntries.filter { it.friendUserId != null }.map { + MessagingFriendInfo( + it.friendUserId!!, + it.friendDisplayName, + it.friendDisplayUsername!!.split("|")[1], + it.bitmojiAvatarId, + it.bitmojiSelfieId + ) + } + + bridgeClient.passGroupsAndFriends( + groups.map { it.toJson() }, + friends.map { it.toJson() } + ) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/XposedLoader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/XposedLoader.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.core + +import de.robv.android.xposed.IXposedHookLoadPackage +import de.robv.android.xposed.callbacks.XC_LoadPackage +import me.rhunk.snapenhance.common.Constants + +class XposedLoader : IXposedHookLoadPackage { + override fun handleLoadPackage(p0: XC_LoadPackage.LoadPackageParam) { + if (p0.packageName != Constants.SNAPCHAT_PACKAGE_NAME) return + SnapEnhance() + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/AbstractAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/AbstractAction.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.action + +import me.rhunk.snapenhance.core.ModContext +import java.io.File + +abstract class AbstractAction{ + lateinit var context: ModContext + + /** + * called when the action is triggered + */ + open fun run() {} + + protected open fun deleteRecursively(parent: File?) { + if (parent == null) return + if (parent.isDirectory) for (child in parent.listFiles()!!) deleteRecursively( + child + ) + if (parent.exists() && (parent.isFile || parent.isDirectory)) { + parent.delete() + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.core.action.impl + +import me.rhunk.snapenhance.core.action.AbstractAction +import java.io.File + +class CleanCache : AbstractAction() { + companion object { + private val FILES = arrayOf( + "files/mbgl-offline.db", + "files/native_content_manager/*", + "files/file_manager/*", + "files/blizzardv2/*", + "files/streaming/*", + "cache/*", + "databases/media_packages", + "databases/simple_db_helper.db", + "databases/journal.db", + "databases/arroyo.db", + "databases/arroyo.db-wal", + "databases/native_content_manager/*" + ) + } + + override fun run() { + FILES.forEach { fileName -> + val fileCache = File(context.androidContext.dataDir, fileName) + if (fileName.endsWith("*")) { + val parent = fileCache.parentFile ?: throw IllegalStateException("Parent file is null") + if (parent.exists()) { + parent.listFiles()?.forEach(this::deleteRecursively) + } + return@forEach + } + if (fileCache.exists()) { + deleteRecursively(fileCache) + } + } + + context.softRestartApp() + } +}+ \ No newline at end of file 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 @@ -0,0 +1,313 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.app.AlertDialog +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 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.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 callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } + + private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } + + private val enterConversationMethod by lazy { + context.classCache.conversationManager.methods.first { it.name == "enterConversation" } + } + private val exitConversationMethod by lazy { + context.classCache.conversationManager.methods.first { it.name == "exitConversation" } + } + private val fetchConversationWithMessagesPaginatedMethod by lazy { + context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } + } + + private val conversationManagerInstance by lazy { + context.feature(Messaging::class).conversationManager + } + + private val dialogLogs = mutableListOf<String>() + private var currentActionDialog: AlertDialog? = null + + private var exportType: ExportFormat? = null + private var mediaToDownload: List<ContentType>? = null + private var amountOfMessages: Int? = null + + private fun logDialog(message: String) { + context.runOnUiThread { + if (dialogLogs.size > 10) dialogLogs.removeAt(0) + dialogLogs.add(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) + .setTitle(context.translation["chat_export.select_export_format"]) + .setItems(ExportFormat.entries.map { it.name }.toTypedArray()) { _, which -> + cont.resumeWith(Result.success(ExportFormat.entries[which])) + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .show() + } + } + + private suspend fun askAmountOfMessages() = suspendCancellableCoroutine { cont -> + context.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<ContentType>() + val contentTypes = arrayOf( + ContentType.SNAP, + ContentType.EXTERNAL_MEDIA, + ContentType.NOTE, + ContentType.STICKER + ) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["chat_export.select_media_type"]) + .setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked -> + val media = contentTypes[which] + if (isChecked) { + mediasToDownload.add(media) + } else if (mediasToDownload.contains(media)) { + mediasToDownload.remove(media) + } + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .setPositiveButton(context.translation["button.ok"]) { _, _ -> + cont.resumeWith(Result.success(mediasToDownload)) + } + .show() + } + } + + override fun run() { + context.coroutineScope.launch(Dispatchers.Main) { + exportType = askExportType() ?: return@launch + mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null + amountOfMessages = askAmountOfMessages() + + val friendFeedEntries = context.database.getFeedEntries(500) + val selectedConversations = mutableListOf<FriendFeedEntry>() + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["chat_export.select_conversation"]) + .setMultiChoiceItems( + friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayUsername!!.split("|").firstOrNull() }.toTypedArray(), + BooleanArray(friendFeedEntries.size) { false } + ) { _, which, isChecked -> + if (isChecked) { + selectedConversations.add(friendFeedEntries[which]) + } else if (selectedConversations.contains(friendFeedEntries[which])) { + selectedConversations.remove(friendFeedEntries[which]) + } + } + .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> + dialog.dismiss() + } + .setNeutralButton(context.translation["chat_export.dialog_neutral_button"]) { _, _ -> + exportChatForConversations(friendFeedEntries) + } + .setPositiveButton(context.translation["chat_export.dialog_positive_button"]) { _, _ -> + exportChatForConversations(selectedConversations) + } + .show() + } + } + + private suspend fun conversationAction(isEntering: Boolean, conversationId: String, conversationType: String?) = suspendCancellableCoroutine { continuation -> + val callback = CallbackBuilder(callbackClass) + .override("onSuccess") { _ -> + continuation.resumeWith(Result.success(Unit)) + } + .override("onError") { + continuation.resumeWith(Result.failure(Exception("Failed to ${if (isEntering) "enter" else "exit"} conversation"))) + }.build() + + if (isEntering) { + enterConversationMethod.invoke( + conversationManagerInstance, + SnapUUID.fromString(conversationId).instanceNonNull(), + enterConversationMethod.parameterTypes[1].enumConstants.first { it.toString() == conversationType }, + callback + ) + } else { + exitConversationMethod.invoke( + conversationManagerInstance, + SnapUUID.fromString(conversationId).instanceNonNull(), + Long.MAX_VALUE, + callback + ) + } + } + + private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long) = suspendCancellableCoroutine { continuation -> + val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass) + .override("onFetchConversationWithMessagesComplete") { param -> + val messagesList = param.arg<List<*>>(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( + conversationManagerInstance, + SnapUUID.fromString(conversationId).instanceNonNull(), + lastMessageId, + 500, + callback + ) + } + + private suspend fun exportFullConversation(friendFeedEntry: FriendFeedEntry) { + //first fetch the first message + val conversationId = friendFeedEntry.key!! + val conversationName = friendFeedEntry.feedDisplayName ?: friendFeedEntry.friendDisplayName!!.split("|").lastOrNull() ?: "unknown" + + 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() + var lastMessageId = foundMessages.firstOrNull()?.messageDescriptor?.messageId ?: run { + logDialog(context.translation["chat_export.no_messages_found"]) + return + } + + 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( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "SnapEnhance/conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}" + ).also { it.parentFile?.mkdirs() } + + 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() + ) + "\n") + + runCatching { + conversationAction(false, conversationId, null) + } + } + + private fun exportChatForConversations(conversations: List<FriendFeedEntry>) { + dialogLogs.clear() + val jobs = mutableListOf<Job>() + + currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["chat_export.exporting_chats"]) + .setCancelable(false) + .setMessage("") + .create() + + val conversationSize = context.translation.format("chat_export.processing_chats", "amount" to conversations.size.toString()) + + logDialog(conversationSize) + + context.coroutineScope.launch { + conversations.forEach { conversation -> + launch { + runCatching { + exportFullConversation(conversation) + }.onFailure { + logDialog(context.translation.format("chat_export.export_fail", "conversation" to conversation.key.toString())) + logDialog(it.stackTraceToString()) + CoreLogger.xposedLog(it) + } + }.also { jobs.add(it) } + } + 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/core/action/impl/OpenMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/OpenMap.kt @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.core.action.impl + +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.core.action.AbstractAction + +class OpenMap: AbstractAction() { + override fun run() { + context.runOnUiThread { + val mapActivityIntent = Intent() + mapActivityIntent.setClassName(BuildConfig.APPLICATION_ID, BuildConfig.APPLICATION_ID + ".ui.MapActivity") + mapActivityIntent.putExtra("location", Bundle().apply { + putDouble("latitude", context.config.experimental.spoof.location.latitude.get().toDouble()) + putDouble("longitude", context.config.experimental.spoof.location.longitude.get().toDouble()) + }) + + context.mainActivity!!.startActivityForResult(mapActivityIntent, 0x1337) + } + } +}+ \ No newline at end of file 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 @@ -10,24 +10,32 @@ import android.os.Handler import android.os.HandlerThread import android.os.IBinder import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.bridge.BridgeInterface import me.rhunk.snapenhance.bridge.ConfigStateListener import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface import me.rhunk.snapenhance.bridge.scripting.IScripting -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.core.messaging.SocialScope -import me.rhunk.snapenhance.data.LocalePair +import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.bridge.FileLoaderWrapper +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import me.rhunk.snapenhance.common.bridge.types.FileActionType +import me.rhunk.snapenhance.common.bridge.types.LocalePair +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.core.ModContext import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.system.exitProcess +fun FileLoaderWrapper.loadFromBridge(bridgeClient: BridgeClient) { + isFileExists = { bridgeClient.isFileExists(fileType) } + read = { bridgeClient.createAndReadFile(fileType, defaultContent) } + write = { bridgeClient.writeFile(fileType, it) } + delete = { bridgeClient.deleteFile(fileType) } +} + class BridgeClient( private val context: ModContext @@ -35,10 +43,6 @@ class BridgeClient( private lateinit var future: CompletableFuture<Boolean> private lateinit var service: BridgeInterface - companion object { - const val BRIDGE_SYNC_ACTION = BuildConfig.APPLICATION_ID + ".core.bridge.SYNC" - } - fun connect(timeout: (Throwable) -> Unit, onResult: (Boolean) -> Unit) { this.future = CompletableFuture() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/FileLoaderWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/FileLoaderWrapper.kt @@ -1,35 +0,0 @@ -package me.rhunk.snapenhance.core.bridge - -import android.content.Context -import me.rhunk.snapenhance.core.bridge.types.BridgeFileType - -open class FileLoaderWrapper( - private val fileType: BridgeFileType, - private val defaultContent: ByteArray -) { - lateinit var isFileExists: () -> Boolean - lateinit var write: (ByteArray) -> Unit - lateinit var read: () -> ByteArray - lateinit var delete: () -> Unit - - fun loadFromContext(context: Context) { - val file = fileType.resolve(context) - isFileExists = { file.exists() } - read = { - if (!file.exists()) { - file.createNewFile() - file.writeBytes("{}".toByteArray(Charsets.UTF_8)) - } - file.readBytes() - } - write = { file.writeBytes(it) } - delete = { file.delete() } - } - - fun loadFromBridge(bridgeClient: BridgeClient) { - isFileExists = { bridgeClient.isFileExists(fileType) } - read = { bridgeClient.createAndReadFile(fileType, defaultContent) } - write = { bridgeClient.writeFile(fileType, it) } - delete = { bridgeClient.deleteFile(fileType) } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/BridgeFileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/BridgeFileType.kt @@ -1,24 +0,0 @@ -package me.rhunk.snapenhance.core.bridge.types - -import android.content.Context -import java.io.File - - -enum class BridgeFileType(val value: Int, val fileName: String, val displayName: String, private val isDatabase: Boolean = false) { - CONFIG(0, "config.json", "Config"), - MAPPINGS(1, "mappings.json", "Mappings"), - MESSAGE_LOGGER_DATABASE(2, "message_logger.db", "Message Logger",true), - PINNED_CONVERSATIONS(3, "pinned_conversations.txt", "Pinned Conversations"); - - fun resolve(context: Context): File = if (isDatabase) { - context.getDatabasePath(fileName) - } else { - File(context.filesDir, fileName) - } - - companion object { - fun fromValue(value: Int): BridgeFileType? { - return entries.firstOrNull { it.value == value } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/FileActionType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/types/FileActionType.kt @@ -1,5 +0,0 @@ -package me.rhunk.snapenhance.core.bridge.types - -enum class FileActionType { - CREATE_AND_READ, READ, WRITE, DELETE, EXISTS -}- \ No newline at end of file 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 @@ -1,100 +0,0 @@ -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.core.Logger -import me.rhunk.snapenhance.core.bridge.BridgeClient -import me.rhunk.snapenhance.data.LocalePair -import java.util.Locale - - -class LocaleWrapper { - companion object { - const val DEFAULT_LOCALE = "en_US" - - fun fetchLocales(context: Context, locale: String = DEFAULT_LOCALE): List<LocalePair> { - val locales = mutableListOf<LocalePair>().apply { - add(LocalePair(DEFAULT_LOCALE, context.resources.assets.open("lang/$DEFAULT_LOCALE.json").bufferedReader().use { it.readText() })) - } - - if (locale == DEFAULT_LOCALE) return locales - - val compatibleLocale = context.resources.assets.list("lang")?.firstOrNull { it.startsWith(locale) }?.substring(0, 5) ?: return locales - - context.resources.assets.open("lang/$compatibleLocale.json").use { inputStream -> - locales.add(LocalePair(compatibleLocale, inputStream.bufferedReader().use { it.readText() })) - } - - return locales - } - - fun fetchAvailableLocales(context: Context): List<String> { - return context.resources.assets.list("lang")?.map { it.substring(0, 5) } ?: listOf() - } - } - - var userLocale = DEFAULT_LOCALE - - private val translationMap = linkedMapOf<String, String>() - - lateinit var loadedLocale: Locale - - private fun load(localePair: LocalePair) { - loadedLocale = localePair.locale.let { Locale(it.substring(0, 2), it.substring(3, 5)) } - - val translations = JsonParser.parseString(localePair.content).asJsonObject - if (translations == null || translations.isJsonNull) { - return - } - - fun scanObject(jsonObject: JsonObject, prefix: String = "") { - jsonObject.entrySet().forEach { - if (it.value.isJsonPrimitive) { - val key = "$prefix${it.key}" - translationMap[key] = it.value.asString - } - if (!it.value.isJsonObject) return@forEach - scanObject(it.value.asJsonObject, "$prefix${it.key}.") - } - } - - scanObject(translations) - } - - fun loadFromBridge(bridgeClient: BridgeClient) { - bridgeClient.fetchLocales(userLocale).forEach { - load(it) - } - } - - fun loadFromContext(context: Context) { - fetchLocales(context, userLocale).forEach { - load(it) - } - } - - fun reloadFromContext(context: Context, locale: String) { - userLocale = locale - translationMap.clear() - loadFromContext(context) - } - - operator fun get(key: String) = translationMap[key] ?: key.also { Logger.directDebug("Missing translation for $key") } - - fun format(key: String, vararg args: Pair<String, String>): String { - return args.fold(get(key)) { acc, pair -> - acc.replace("{${pair.first}}", pair.second) - } - } - - fun getCategory(key: String): LocaleWrapper { - return LocaleWrapper().apply { - translationMap.putAll( - this@LocaleWrapper.translationMap - .filterKeys { it.startsWith("$key.") } - .mapKeys { it.key.substring(key.length + 1) } - ) - } - } -}- \ No newline at end of file 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 @@ -1,151 +0,0 @@ -package me.rhunk.snapenhance.core.bridge.wrapper - -import android.content.Context -import com.google.gson.GsonBuilder -import com.google.gson.JsonElement -import com.google.gson.JsonParser -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.Logger -import me.rhunk.snapenhance.core.bridge.FileLoaderWrapper -import me.rhunk.snapenhance.core.bridge.types.BridgeFileType -import me.rhunk.snapenhance.mapper.Mapper -import me.rhunk.snapenhance.mapper.impl.* -import java.util.concurrent.ConcurrentHashMap -import kotlin.system.measureTimeMillis - -class MappingsWrapper : FileLoaderWrapper(BridgeFileType.MAPPINGS, "{}".toByteArray(Charsets.UTF_8)) { - companion object { - private val gson = GsonBuilder().setPrettyPrinting().create() - private val mappers = arrayOf( - BCryptClassMapper::class, - CallbackMapper::class, - DefaultMediaItemMapper::class, - MediaQualityLevelProviderMapper::class, - OperaPageViewControllerMapper::class, - PlatformAnalyticsCreatorMapper::class, - PlusSubscriptionMapper::class, - ScCameraSettingsMapper::class, - StoryBoostStateMapper::class, - FriendsFeedEventDispatcherMapper::class, - CompositeConfigurationProviderMapper::class, - ScoreUpdateMapper::class, - FriendRelationshipChangerMapper::class, - ViewBinderMapper::class, - ) - } - - private lateinit var context: Context - - private val mappings = ConcurrentHashMap<String, Any>() - private var snapBuildNumber: Long = 0 - - fun init(context: Context) { - this.context = context - snapBuildNumber = getSnapchatVersionCode() - - if (isFileExists()) { - runCatching { - loadCached() - }.onFailure { - delete() - } - } - } - - fun getSnapchatPackageInfo() = runCatching { - context.packageManager.getPackageInfo( - Constants.SNAPCHAT_PACKAGE_NAME, - 0 - ) - }.getOrNull() - - fun getSnapchatVersionCode() = getSnapchatPackageInfo()?.longVersionCode ?: -1 - fun getApplicationSourceDir() = getSnapchatPackageInfo()?.applicationInfo?.sourceDir - fun getGeneratedBuildNumber() = snapBuildNumber - - fun isMappingsOutdated(): Boolean { - return snapBuildNumber != getSnapchatVersionCode() || isMappingsLoaded().not() - } - - fun isMappingsLoaded(): Boolean { - return mappings.isNotEmpty() - } - - private fun loadCached() { - if (!isFileExists()) { - throw Exception("Mappings file does not exist") - } - val mappingsObject = JsonParser.parseString(read().toString(Charsets.UTF_8)).asJsonObject.also { - snapBuildNumber = it["snap_build_number"].asLong - } - - mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> -> - if (value.isJsonArray) { - mappings[key] = gson.fromJson(value, ArrayList::class.java) - return@forEach - } - if (value.isJsonObject) { - mappings[key] = gson.fromJson(value, ConcurrentHashMap::class.java) - return@forEach - } - mappings[key] = value.asString - } - } - - fun refresh() { - snapBuildNumber = getSnapchatVersionCode() - val mapper = Mapper(*mappers) - - runCatching { - mapper.loadApk(getApplicationSourceDir() ?: throw Exception("Failed to get APK")) - }.onFailure { - throw Exception("Failed to load APK", it) - } - - measureTimeMillis { - val result = mapper.start().apply { - addProperty("snap_build_number", snapBuildNumber) - } - write(result.toString().toByteArray()) - }.also { - Logger.directDebug("Generated mappings in $it ms") - } - } - - fun getMappedObject(key: String): Any { - if (mappings.containsKey(key)) { - return mappings[key]!! - } - throw Exception("No mapping found for $key") - } - - fun getMappedObjectNullable(key: String): Any? { - return mappings[key] - } - - fun getMappedClass(className: String): Class<*> { - return context.classLoader.loadClass(getMappedObject(className) as String) - } - - fun getMappedClass(key: String, subKey: String): Class<*> { - return context.classLoader.loadClass(getMappedValue(key, subKey)) - } - - fun getMappedValue(key: String): String { - return getMappedObject(key) as String - } - - @Suppress("UNCHECKED_CAST") - fun <T : Any> getMappedList(key: String): List<T> { - return listOf(getMappedObject(key) as List<T>).flatten() - } - - fun getMappedValue(key: String, subKey: String): String { - return getMappedMap(key)[subKey] as String - } - - @Suppress("UNCHECKED_CAST") - fun getMappedMap(key: String): Map<String, *> { - return getMappedObject(key) as Map<String, *> - } -}- \ No newline at end of file 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 @@ -1,79 +0,0 @@ -package me.rhunk.snapenhance.core.bridge.wrapper - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.bridge.MessageLoggerInterface -import me.rhunk.snapenhance.core.util.SQLiteDatabaseHelper -import java.io.File -import java.util.UUID - -class MessageLoggerWrapper( - private val databaseFile: File -): MessageLoggerInterface.Stub() { - private lateinit var database: SQLiteDatabase - - fun init() { - database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) - SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( - "messages" to listOf( - "id INTEGER PRIMARY KEY", - "conversation_id VARCHAR", - "message_id BIGINT", - "message_data BLOB" - ) - )) - } - - override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { - if (conversationId.any { - runCatching { UUID.fromString(it) }.isFailure - }) return longArrayOf() - - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ - conversationId.joinToString( - "," - ) { "'$it'" } - }) ORDER BY message_id DESC LIMIT $limit", null) - - val ids = mutableListOf<Long>() - while (cursor.moveToNext()) { - ids.add(cursor.getLong(0)) - } - cursor.close() - return ids.toLongArray() - } - - override fun getMessage(conversationId: String?, id: Long): ByteArray? { - val cursor = database.rawQuery("SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, id.toString())) - val message: ByteArray? = if (cursor.moveToFirst()) { - cursor.getBlob(0) - } else { - null - } - cursor.close() - return message - } - - override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray): Boolean { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - cursor.close() - if (state) { - return false - } - database.insert("messages", null, ContentValues().apply { - put("conversation_id", conversationId) - put("message_id", messageId) - put("message_data", serializedMessage) - }) - return true - } - - fun clearMessages() { - database.execSQL("DELETE FROM messages") - } - - override fun deleteMessage(conversationId: String, messageId: Long) { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigContainer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/ConfigContainer.kt @@ -1,82 +0,0 @@ -package me.rhunk.snapenhance.core.config - -import com.google.gson.JsonObject -import kotlin.reflect.KProperty - -typealias ConfigParamsBuilder = ConfigParams.() -> Unit - -open class ConfigContainer( - val hasGlobalState: Boolean = false -) { - var parentContainerKey: PropertyKey<*>? = null - val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() - var globalState: Boolean? = null - - private inline fun <T> registerProperty( - key: String, - type: DataProcessors.PropertyDataProcessor<*>, - defaultValue: PropertyValue<T>, - params: ConfigParams.() -> Unit = {}, - propertyKeyCallback: (PropertyKey<*>) -> Unit = {} - ): PropertyValue<T> { - val propertyKey = PropertyKey({ parentContainerKey }, key, type, ConfigParams().also { it.params() }) - properties[propertyKey] = defaultValue - propertyKeyCallback(propertyKey) - return defaultValue - } - - protected fun boolean(key: String, defaultValue: Boolean = false, params: ConfigParamsBuilder = {}) = - registerProperty(key, DataProcessors.BOOLEAN, PropertyValue(defaultValue), params) - - protected fun integer(key: String, defaultValue: Int = 0, params: ConfigParamsBuilder = {}) = - registerProperty(key, DataProcessors.INTEGER, PropertyValue(defaultValue), params) - - protected fun float(key: String, defaultValue: Float = 0f, params: ConfigParamsBuilder = {}) = - registerProperty(key, DataProcessors.FLOAT, PropertyValue(defaultValue), params) - - protected fun string(key: String, defaultValue: String = "", params: ConfigParamsBuilder = {}) = - registerProperty(key, DataProcessors.STRING, PropertyValue(defaultValue), params) - - protected fun multiple( - key: String, - vararg values: String = emptyArray(), - params: ConfigParamsBuilder = {} - ) = registerProperty(key, - DataProcessors.STRING_MULTIPLE_SELECTION, PropertyValue(mutableListOf<String>(), defaultValues = values.toList()), params) - - //null value is considered as Off/Disabled - protected fun unique( - key: String, - vararg values: String = emptyArray(), - params: ConfigParamsBuilder = {} - ) = registerProperty(key, - DataProcessors.STRING_UNIQUE_SELECTION, PropertyValue("null", defaultValues = values.toList()), params) - - protected fun <T : ConfigContainer> container( - key: String, - container: T, - params: ConfigParamsBuilder = {} - ) = registerProperty(key, DataProcessors.container(container), PropertyValue(container), params) { - container.parentContainerKey = it - }.get() - - fun toJson(): JsonObject { - val json = JsonObject() - properties.forEach { (propertyKey, propertyValue) -> - val serializedValue = propertyValue.getNullable()?.let { propertyKey.dataType.serializeAny(it) } - json.add(propertyKey.name, serializedValue) - } - return json - } - - 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)) - } - } - - operator fun getValue(t: Any?, property: KProperty<*>) = this.globalState - operator fun setValue(t: Any?, property: KProperty<*>, t1: Boolean?) { this.globalState = t1 } -}- \ 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 @@ -1,117 +0,0 @@ -package me.rhunk.snapenhance.core.config - -import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper -import kotlin.reflect.KProperty - -data class PropertyPair<T>( - val key: PropertyKey<T>, - val value: PropertyValue<*> -) { - val name get() = key.name -} - -enum class FeatureNotice( - val id: Int, - val key: String -) { - UNSTABLE(0b0001, "unstable"), - BAN_RISK(0b0010, "ban_risk"), - INTERNAL_BEHAVIOR(0b0100, "internal_behavior") -} - -enum class ConfigFlag( - val id: Int -) { - NO_TRANSLATE(0b000001), - HIDDEN(0b000010), - FOLDER(0b000100), - NO_DISABLE_KEY(0b001000), - REQUIRE_RESTART(0b010000), - REQUIRE_CLEAN_CACHE(0b100000) -} - -class ConfigParams( - private var _flags: Int? = null, - private var _notices: Int? = null, - - var icon: String? = null, - var disabledKey: String? = null, - var customTranslationPath: String? = null, - var customOptionTranslationPath: String? = null -) { - 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() - - fun addNotices(vararg values: FeatureNotice) { - this._notices = (this._notices ?: 0) or values.fold(0) { acc, featureNotice -> acc or featureNotice.id } - } - - fun addFlags(vararg values: ConfigFlag) { - this._flags = (this._flags ?: 0) or values.fold(0) { acc, flag -> acc or flag.id } - } - - fun requireRestart() { - addFlags(ConfigFlag.REQUIRE_RESTART) - } - fun requireCleanCache() { - addFlags(ConfigFlag.REQUIRE_CLEAN_CACHE) - } -} - -class PropertyValue<T>( - private var value: T? = null, - val defaultValues: List<*>? = null -) { - inner class PropertyValueNullable { - fun get() = value - operator fun getValue(t: Any?, property: KProperty<*>): T? = getNullable() - operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) - } - - fun nullable() = PropertyValueNullable() - - 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?) { setAny(value) } - @Suppress("UNCHECKED_CAST") - fun setAny(value: Any?) { this.value = value as T? } - - operator fun getValue(t: Any?, property: KProperty<*>): T = get() - operator fun setValue(t: Any?, property: KProperty<*>, t1: T?) = set(t1) -} - -data class PropertyKey<T>( - private val _parent: () -> PropertyKey<*>?, - val name: String, - val dataType: DataProcessors.PropertyDataProcessor<T>, - val params: ConfigParams = ConfigParams(), -) { - private val parentKey by lazy { _parent() } - - fun propertyOption(translation: LocaleWrapper, key: String): String { - if (key == "null") { - return translation[params.disabledKey ?: "manager.sections.features.disabled"] - } - - return if (!params.flags.contains(ConfigFlag.NO_TRANSLATE)) - translation[params.customOptionTranslationPath?.let { - "$it.$key" - } ?: "features.options.${name}.$key"] - else key - } - - fun propertyName() = propertyTranslationPath() + ".name" - fun propertyDescription() = propertyTranslationPath() + ".description" - - fun propertyTranslationPath(): String { - params.customTranslationPath?.let { - return it - } - return parentKey?.let { - "${it.propertyTranslationPath()}.properties.$name" - } ?: "features.properties.$name" - } -} - diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/DataProcessors.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/DataProcessors.kt @@ -1,94 +0,0 @@ -package me.rhunk.snapenhance.core.config - -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonNull -import com.google.gson.JsonObject -import com.google.gson.JsonPrimitive - -object DataProcessors { - enum class Type { - STRING, - BOOLEAN, - INTEGER, - FLOAT, - STRING_MULTIPLE_SELECTION, - STRING_UNIQUE_SELECTION, - CONTAINER, - } - - data class PropertyDataProcessor<T> - internal constructor( - val type: Type, - private val serialize: (T) -> JsonElement, - private val deserialize: (JsonElement) -> T - ) { - @Suppress("UNCHECKED_CAST") - fun serializeAny(value: Any) = serialize(value as T) - fun deserializeAny(value: JsonElement) = deserialize(value) - } - - val STRING = PropertyDataProcessor( - type = Type.STRING, - serialize = { - if (it != null) JsonPrimitive(it) - else JsonNull.INSTANCE - }, - deserialize = { - if (it.isJsonNull) null - else it.asString - }, - ) - - val BOOLEAN = PropertyDataProcessor( - type = Type.BOOLEAN, - serialize = { - if (it) JsonPrimitive(true) - else JsonPrimitive(false) - }, - deserialize = { it.asBoolean }, - ) - - val INTEGER = PropertyDataProcessor( - type = Type.INTEGER, - serialize = { JsonPrimitive(it) }, - deserialize = { it.asInt }, - ) - - val FLOAT = PropertyDataProcessor( - type = Type.FLOAT, - serialize = { JsonPrimitive(it) }, - deserialize = { it.asFloat }, - ) - - val STRING_MULTIPLE_SELECTION = PropertyDataProcessor( - type = Type.STRING_MULTIPLE_SELECTION, - serialize = { JsonArray().apply { it.forEach { add(it) } } }, - deserialize = { obj -> - obj.asJsonArray.map { it.asString }.toMutableList() - }, - ) - - val STRING_UNIQUE_SELECTION = PropertyDataProcessor( - type = Type.STRING_UNIQUE_SELECTION, - serialize = { JsonPrimitive(it) }, - deserialize = { obj -> obj.takeIf { !it.isJsonNull }?.asString } - ) - - fun <T : ConfigContainer> container(container: T) = PropertyDataProcessor( - type = Type.CONTAINER, - serialize = { - JsonObject().apply { - addProperty("state", it.globalState) - add("properties", it.toJson()) - } - }, - deserialize = { obj -> - val jsonObject = obj.asJsonObject - container.apply { - globalState = jsonObject["state"].takeIf { !it.isJsonNull }?.asBoolean - fromJson(jsonObject["properties"].asJsonObject) - } - }, - ) -}- \ No newline at end of file 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 @@ -1,132 +0,0 @@ -package me.rhunk.snapenhance.core.config - -import android.content.Context -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.google.gson.JsonObject -import me.rhunk.snapenhance.bridge.ConfigStateListener -import me.rhunk.snapenhance.core.Logger -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 - -class ModConfig { - var locale: String = LocaleWrapper.DEFAULT_LOCALE - - private val gson: Gson = GsonBuilder().setPrettyPrinting().create() - private val file = FileLoaderWrapper(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8)) - var wasPresent by Delegates.notNull<Boolean>() - - /* Used to notify the bridge client about config changes */ - var configStateListener: ConfigStateListener? = null - lateinit var root: RootConfig - private set - - private fun load() { - root = RootConfig() - wasPresent = file.isFileExists() - if (!file.isFileExists()) { - writeConfig() - return - } - - runCatching { - loadConfig() - }.onFailure { - writeConfig() - } - } - - private fun loadConfig() { - val configFileContent = file.read() - val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java) - locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE - root.fromJson(configObject) - } - - fun exportToString(): String { - val configObject = root.toJson() - configObject.addProperty("_locale", locale) - return configObject.toString() - } - - fun reset() { - root = RootConfig() - writeConfig() - } - - fun writeConfig() { - var shouldRestart = false - var shouldCleanCache = false - var configChanged = false - - fun compareDiff(originalContainer: ConfigContainer, modifiedContainer: ConfigContainer) { - val parentContainerFlags = modifiedContainer.parentContainerKey?.params?.flags ?: emptySet() - - parentContainerFlags.takeIf { originalContainer.hasGlobalState }?.apply { - if (modifiedContainer.globalState != originalContainer.globalState) { - configChanged = true - if (contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true - if (contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true - } - } - - for (property in modifiedContainer.properties) { - val modifiedValue = property.value.getNullable() - val originalValue = originalContainer.properties.entries.firstOrNull { - it.key.name == property.key.name - }?.value?.getNullable() - - if (originalValue is ConfigContainer && modifiedValue is ConfigContainer) { - compareDiff(originalValue, modifiedValue) - continue - } - - if (modifiedValue != originalValue) { - val flags = property.key.params.flags + parentContainerFlags - configChanged = true - if (flags.contains(ConfigFlag.REQUIRE_RESTART)) shouldRestart = true - if (flags.contains(ConfigFlag.REQUIRE_CLEAN_CACHE)) shouldCleanCache = true - } - } - } - - configStateListener?.also { - runCatching { - compareDiff(RootConfig().apply { - fromJson(gson.fromJson(file.read().toString(Charsets.UTF_8), JsonObject::class.java)) - }, root) - - if (configChanged) { - it.onConfigChanged() - if (shouldCleanCache) it.onCleanCacheRequired() - else if (shouldRestart) it.onRestartRequired() - } - }.onFailure { - Logger.directError("Error while calling config state listener", it, "ConfigStateListener") - } - } - - file.write(exportToString().toByteArray(Charsets.UTF_8)) - } - - fun loadFromString(string: String) { - val configObject = gson.fromJson(string, JsonObject::class.java) - locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE - root.fromJson(configObject) - writeConfig() - } - - fun loadFromContext(context: Context) { - file.loadFromContext(context) - load() - } - - fun loadFromBridge(bridgeClient: BridgeClient) { - file.loadFromBridge(bridgeClient) - load() - } -}- \ No newline at end of file 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 @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -import me.rhunk.snapenhance.core.config.ConfigFlag -import me.rhunk.snapenhance.core.config.FeatureNotice -import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks - -class Camera : ConfigContainer() { - val disable = boolean("disable_camera") - 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.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 @@ -1,50 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -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 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) } - } - - val saveFolder = string("save_folder") { addFlags(ConfigFlag.FOLDER); requireRestart() } - val autoDownloadSources = multiple("auto_download_sources", - "friend_snaps", - "friend_stories", - "public_stories", - "spotlight" - ) - val preventSelfAutoDownload = boolean("prevent_self_auto_download") - val pathFormat = multiple("path_format", - "create_author_folder", - "create_source_folder", - "append_hash", - "append_source", - "append_username", - "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.UNSTABLE) } - 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 downloadProfilePictures = boolean("download_profile_pictures") { 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 { - set(mutableListOf("started", "success")) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/E2EEConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/E2EEConfig.kt @@ -1,8 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer - -class E2EEConfig : ConfigContainer(hasGlobalState = true) { - val encryptedMessageIndicator = boolean("encrypted_message_indicator") - val forceMessageEncryption = boolean("force_message_encryption") -}- \ No newline at end of file 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 @@ -1,27 +0,0 @@ -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"; requireRestart() } - val spoof = container("spoof", Spoof()) { icon = "Fingerprint" } - val appPasscode = string("app_passcode") - 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.BAN_RISK)} - val noFriendScoreDelay = boolean("no_friend_score_delay") { requireRestart()} - val e2eEncryption = container("e2ee", E2EEConfig()) { requireRestart()} - val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { - addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) - requireRestart() - } - 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.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 @@ -1,14 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -import me.rhunk.snapenhance.core.config.FeatureNotice - -class Global : ConfigContainer() { - val snapchatPlus = boolean("snapchat_plus") { addNotices(FeatureNotice.BAN_RISK); requireRestart() } - val disableMetrics = boolean("disable_metrics") - val blockAds = boolean("block_ads") - val bypassVideoLengthRestriction = unique("bypass_video_length_restriction", "split", "single") { addNotices(FeatureNotice.BAN_RISK); requireRestart() } - val disableGooglePlayDialogs = boolean("disable_google_play_dialogs") { requireRestart() } - val forceMediaSourceQuality = boolean("force_media_source_quality") - 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 @@ -1,31 +0,0 @@ -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 MessagingTweaks : ConfigContainer() { - val anonymousStoryViewing = boolean("anonymous_story_viewing") - 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", - "NOTE", - "EXTERNAL_MEDIA", - "STICKER" - ) { requireRestart() } - val snapToChatMedia = boolean("snap_to_chat_media") { requireRestart() } - 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") { requireRestart() } - val notificationBlacklist = multiple("notification_blacklist", *NotificationType.getIncomingValues().map { it.key }.toTypedArray()) { - customOptionTranslationPath = "features.options.notifications" - } - val messageLogger = boolean("message_logger") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } - 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/NativeHooks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/NativeHooks.kt @@ -1,8 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer - -class NativeHooks: ConfigContainer(hasGlobalState = true) { - val disableBitmoji = boolean("disable_bitmoji") - val fixGalleryMediaOverride = boolean("fix_gallery_media_override") -}- \ 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 @@ -1,18 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -import me.rhunk.snapenhance.core.config.FeatureNotice - -class RootConfig : ConfigContainer() { - val downloader = container("downloader", DownloaderConfig()) { icon = "Download"} - val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"} - val messaging = container("messaging", MessagingTweaks()) { icon = "Send" } - val global = container("global", Global()) { icon = "MiscellaneousServices" } - val rules = container("rules", Rules()) { icon = "Rule" } - val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() } - val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } - val experimental = container("experimental", Experimental()) { - icon = "Science"; addNotices(FeatureNotice.UNSTABLE) - } - val scripting = container("scripting", Scripting()) { icon = "DataObject" } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Rules.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Rules.kt @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -import me.rhunk.snapenhance.core.config.PropertyValue -import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.core.messaging.RuleState - - -class Rules : ConfigContainer() { - private val rules = mutableMapOf<MessagingRuleType, PropertyValue<String>>() - - fun getRuleState(ruleType: MessagingRuleType): RuleState? { - return rules[ruleType]?.getNullable()?.let { RuleState.getByName(it) } - } - - init { - MessagingRuleType.entries.filter { it.listMode }.forEach { ruleType -> - rules[ruleType] = unique(ruleType.key,"whitelist", "blacklist") { - customTranslationPath = "rules.properties.${ruleType.key}" - customOptionTranslationPath = "rules.modes" - }.apply { - set("whitelist") - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Scripting.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/Scripting.kt @@ -1,10 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -import me.rhunk.snapenhance.core.config.ConfigContainer -import me.rhunk.snapenhance.core.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) -}- \ No newline at end of file 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 @@ -1,24 +0,0 @@ -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) { - val latitude = float("location_latitude") - val longitude = float("location_longitude") - } - val location = container("location", Location()) - - inner class Device : ConfigContainer(hasGlobalState = true) { - 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 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/core/config/impl/StreaksReminderConfig.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/StreaksReminderConfig.kt @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.core.config.impl - -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/config/impl/UserInterfaceTweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/config/impl/UserInterfaceTweaks.kt @@ -1,42 +0,0 @@ -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() { - inner class BootstrapOverride : ConfigContainer() { - val appAppearance = unique("app_appearance", "always_light", "always_dark") - val homeTab = unique("home_tab", *ClientBootstrapOverride.tabs) { addNotices(FeatureNotice.UNSTABLE) } - } - - inner class FriendFeedMessagePreview : ConfigContainer(hasGlobalState = true) { - val amount = integer("amount", defaultValue = 1) - } - - val friendFeedMenuButtons = multiple( - "friend_feed_menu_buttons","conversation_info", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() - ).apply { - set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key)) - } - 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 bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } - val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() } - val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() } - val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() } - val hideStorySections = multiple("hide_story_sections", - "hide_friend_suggestions", "hide_friends", "hide_suggested", "hide_for_you") { requireRestart() } - val hideUiComponents = multiple("hide_ui_components", - "hide_voice_record_button", - "hide_stickers_button", - "hide_live_location_share_button", - "hide_chat_call_buttons", - "hide_profile_call_buttons" - ) { requireRestart() } - val ddBitmojiSelfie = boolean("2d_bitmoji_selfie") { requireCleanCache() } - val disableSpotlight = boolean("disable_spotlight") { 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/data/SnapClassCache.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/data/SnapClassCache.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.core.data + +class SnapClassCache ( + private val classLoader: ClassLoader +) { + val snapUUID by lazy { findClass("com.snapchat.client.messaging.UUID") } + val snapManager by lazy { findClass("com.snapchat.client.messaging.SnapManager\$CppProxy") } + val conversationManager by lazy { findClass("com.snapchat.client.messaging.ConversationManager\$CppProxy") } + 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 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") } + val localMessageContent by lazy { findClass("com.snapchat.client.messaging.LocalMessageContent") } + 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")} + 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 { + classLoader.loadClass(className) + } catch (e: ClassNotFoundException) { + throw RuntimeException("Failed to find class $className", e) + } + } +}+ \ 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 @@ -2,15 +2,16 @@ package me.rhunk.snapenhance.core.database import android.annotation.SuppressLint import android.database.sqlite.SQLiteDatabase -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 -import me.rhunk.snapenhance.core.database.objects.StoryEntry -import me.rhunk.snapenhance.core.database.objects.UserConversationLink -import me.rhunk.snapenhance.core.util.ktx.getStringOrNull -import me.rhunk.snapenhance.manager.Manager +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.getStringOrNull +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.manager.Manager import java.io.File @SuppressLint("Range") @@ -56,7 +57,7 @@ class DatabaseAccess(private val context: ModContext) : Manager { return runCatching { query(database) }.onFailure { - Logger.xposedLog("Database operation failed", it) + CoreLogger.xposedLog("Database operation failed", it) }.getOrNull() } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseObject.kt @@ -1,7 +0,0 @@ -package me.rhunk.snapenhance.core.database - -import android.database.Cursor - -interface DatabaseObject { - fun write(cursor: Cursor) -} 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 @@ -1,43 +0,0 @@ -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.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 - -@Suppress("ArrayInDataClass") -data class ConversationMessage( - var clientConversationId: String? = null, - var clientMessageId: Int = 0, - var serverMessageId: Int = 0, - var messageContent: ByteArray? = null, - var isSaved: Int = 0, - var isViewedByUser: Int = 0, - var contentType: Int = 0, - var creationTimestamp: Long = 0, - var readTimestamp: Long = 0, - var senderId: String? = null -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - with(cursor) { - clientConversationId = getStringOrNull("client_conversation_id") - clientMessageId = getInteger("client_message_id") - serverMessageId = getInteger("server_message_id") - messageContent = getBlobOrNull("message_content") - isSaved = getInteger("is_saved") - isViewedByUser = getInteger("is_viewed_by_user") - contentType = getInteger("content_type") - creationTimestamp = getLong("creation_timestamp") - readTimestamp = getLong("read_timestamp") - senderId = getStringOrNull("sender_id") - } - } -} 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 @@ -1,47 +0,0 @@ -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.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, - var feedDisplayName: String? = null, - var participantsSize: Int = 0, - var lastInteractionTimestamp: Long = 0, - var displayTimestamp: Long = 0, - var displayInteractionType: String? = null, - var lastInteractionUserId: Int? = null, - var key: String? = null, - var friendUserId: String? = null, - var friendDisplayName: String? = null, - var friendDisplayUsername: String? = null, - var friendLinkType: Int? = null, - var bitmojiAvatarId: String? = null, - var bitmojiSelfieId: String? = null, -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - with(cursor) { - id = getInteger("_id") - feedDisplayName = getStringOrNull("feedDisplayName") - participantsSize = getInteger("participantsSize") - lastInteractionTimestamp = getLong("lastInteractionTimestamp") - displayTimestamp = getLong("displayTimestamp") - displayInteractionType = getStringOrNull("displayInteractionType") - lastInteractionUserId = getIntOrNull("lastInteractionUserId") - key = getStringOrNull("key") - friendUserId = getStringOrNull("friendUserId") - friendDisplayName = getStringOrNull("friendDisplayName") - friendDisplayUsername = getStringOrNull("friendDisplayUsername") - friendLinkType = getIntOrNull("friendLinkType") - bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") - bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") - } - } -} 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 @@ -1,72 +0,0 @@ -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.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, - var lastModifiedTimestamp: Long = 0, - var username: String? = null, - var userId: String? = null, - var displayName: String? = null, - var bitmojiAvatarId: String? = null, - var bitmojiSelfieId: String? = null, - var bitmojiSceneId: String? = null, - var bitmojiBackgroundId: String? = null, - var friendmojis: String? = null, - var friendmojiCategories: String? = null, - var snapScore: Int = 0, - var birthday: Long = 0, - var addedTimestamp: Long = 0, - var reverseAddedTimestamp: Long = 0, - var serverDisplayName: String? = null, - var streakLength: Int = 0, - var streakExpirationTimestamp: Long = 0, - var reverseBestFriendRanking: Int = 0, - var isPinnedBestFriend: Int = 0, - var plusBadgeVisibility: Int = 0, - var usernameForSorting: String? = null, - var friendLinkType: Int = 0, - var postViewEmoji: String? = null, -) : DatabaseObject, SerializableDataObject() { - val mutableUsername get() = username?.split("|")?.last() - val firstCreatedUsername get() = username?.split("|")?.first() - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - with(cursor) { - id = getInteger("_id") - lastModifiedTimestamp = getLong("_lastModifiedTimestamp") - username = getStringOrNull("username") - userId = getStringOrNull("userId") - displayName = getStringOrNull("displayName") - bitmojiAvatarId = getStringOrNull("bitmojiAvatarId") - bitmojiSelfieId = getStringOrNull("bitmojiSelfieId") - bitmojiSceneId = getStringOrNull("bitmojiSceneId") - bitmojiBackgroundId = getStringOrNull("bitmojiBackgroundId") - friendmojis = getStringOrNull("friendmojis") - friendmojiCategories = getStringOrNull("friendmojiCategories") - snapScore = getInteger("score") - birthday = getLong("birthday") - addedTimestamp = getLong("addedTimestamp") - reverseAddedTimestamp = getLong("reverseAddedTimestamp") - serverDisplayName = getStringOrNull("serverDisplayName") - streakLength = getInteger("streakLength") - streakExpirationTimestamp = getLong("streakExpiration") - reverseBestFriendRanking = getInteger("reverseBestFriendRanking") - usernameForSorting = getStringOrNull("usernameForSorting") - friendLinkType = getInteger("friendLinkType") - postViewEmoji = getStringOrNull("postViewEmoji") - if (getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend = - getInteger("isPinnedBestFriend") - if (getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility = - getInteger("plusBadgeVisibility") - } - } - -} 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 @@ -1,27 +0,0 @@ -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.core.util.ktx.getInteger -import me.rhunk.snapenhance.core.util.ktx.getStringOrNull - -data class StoryEntry( - var id: Int = 0, - var storyId: String? = null, - var displayName: String? = null, - var isLocal: Boolean? = null, - var userId: String? = null -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - with(cursor) { - id = getInteger("_id") - storyId = getStringOrNull("storyId") - displayName = getStringOrNull("displayName") - isLocal = getInteger("isLocal") == 1 - userId = getStringOrNull("userId") - } - } -} 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 @@ -1,23 +0,0 @@ -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.core.util.ktx.getInteger -import me.rhunk.snapenhance.core.util.ktx.getStringOrNull - -class UserConversationLink( - var userId: String? = null, - var clientConversationId: String? = null, - var conversationType: Int = 0 -) : DatabaseObject { - - @SuppressLint("Range") - override fun write(cursor: Cursor) { - with(cursor) { - userId = getStringOrNull("user_id") - clientConversationId = getStringOrNull("client_conversation_id") - conversationType = getInteger("conversation_type") - } - } -} 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 @@ -1,80 +0,0 @@ -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.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 -import me.rhunk.snapenhance.features.impl.downloader.decoder.AttachmentType - -class DownloadManagerClient ( - private val context: ModContext, - private val metadata: DownloadMetadata, - private val callback: DownloadCallback -) { - companion object { - const val DOWNLOAD_REQUEST_EXTRA = "request" - const val DOWNLOAD_METADATA_EXTRA = "metadata" - } - - private fun enqueueDownloadRequest(request: DownloadRequest) { - context.bridgeClient.enqueueDownload(Intent().apply { - putExtras(Bundle().apply { - putString(DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request)) - putString(DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata)) - }) - }, callback) - } - - fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) { - enqueueDownloadRequest( - DownloadRequest( - inputMedias = arrayOf( - InputMedia( - content = playlistUrl, - type = DownloadMediaType.REMOTE_MEDIA - ) - ), - dashOptions = DashOptions(offsetTime, duration), - flags = DownloadRequest.Flags.IS_DASH_PLAYLIST - ) - ) - } - - fun downloadSingleMedia( - mediaData: String, - mediaType: DownloadMediaType, - encryption: MediaEncryptionKeyPair? = null, - attachmentType: AttachmentType? = null - ) { - enqueueDownloadRequest( - DownloadRequest( - inputMedias = arrayOf( - InputMedia( - content = mediaData, - type = mediaType, - encryption = encryption, - attachmentType = attachmentType?.name - ) - ) - ) - ) - } - - fun downloadMediaWithOverlay( - original: InputMedia, - overlay: InputMedia, - ) { - enqueueDownloadRequest( - DownloadRequest( - inputMedias = arrayOf(original, overlay), - flags = DownloadRequest.Flags.MERGE_OVERLAY - ) - ) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMediaType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadMediaType.kt @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -import android.net.Uri - -enum class DownloadMediaType { - PROTO_MEDIA, - DIRECT_MEDIA, - REMOTE_MEDIA, - LOCAL_MEDIA; - - companion object { - fun fromUri(uri: Uri): DownloadMediaType { - return when (uri.scheme) { - "proto" -> PROTO_MEDIA - "direct" -> DIRECT_MEDIA - "http", "https" -> REMOTE_MEDIA - "file" -> LOCAL_MEDIA - else -> throw IllegalArgumentException("Unknown uri scheme: ${uri.scheme}") - } - } - } -}- \ No newline at end of file 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 @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -data class DownloadMetadata( - val mediaIdentifier: String?, - val outputPath: 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/DownloadRequest.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadRequest.kt @@ -1,28 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - - -data class DashOptions(val offsetTime: Long, val duration: Long?) -data class InputMedia( - val content: String, - val type: DownloadMediaType, - val encryption: MediaEncryptionKeyPair? = null, - val attachmentType: String? = null, - val isOverlay: Boolean = false, -) - -class DownloadRequest( - val inputMedias: Array<InputMedia>, - val dashOptions: DashOptions? = null, - private val flags: Int = 0, -) { - object Flags { - const val MERGE_OVERLAY = 1 - const val IS_DASH_PLAYLIST = 2 - } - - val isDashPlaylist: Boolean - get() = flags and Flags.IS_DASH_PLAYLIST != 0 - - val shouldMergeOverlay: Boolean - get() = flags and Flags.MERGE_OVERLAY != 0 -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadStage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/DownloadStage.kt @@ -1,14 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -enum class DownloadStage( - val isFinalStage: Boolean = false, -) { - PENDING(false), - DOWNLOADING(false), - MERGING(false), - DOWNLOADED(true), - SAVED(true), - MERGE_FAILED(true), - FAILED(true), - CANCELLED(true) -}- \ 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 @@ -1,28 +0,0 @@ -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 entries.find { it.key == key } ?: NONE - } - } -}- \ No newline at end of file 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 @@ -1,30 +0,0 @@ -@file:OptIn(ExperimentalEncodingApi::class) - -package me.rhunk.snapenhance.core.download.data - -import me.rhunk.snapenhance.data.wrapper.impl.media.EncryptionWrapper -import java.io.InputStream -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 - -// key and iv are base64 encoded into url safe strings -data class MediaEncryptionKeyPair( - val key: String, - val iv: String -) { - fun decryptInputStream(inputStream: InputStream): InputStream { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.UrlSafe.decode(key), "AES"), IvParameterSpec(Base64.UrlSafe.decode(iv))) - return CipherInputStream(inputStream, cipher) - } -} - -fun Pair<ByteArray, ByteArray>.toKeyPair() - = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second)) - -fun EncryptionWrapper.toKeyPair() - = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec))- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/SplitMediaAssetType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/download/data/SplitMediaAssetType.kt @@ -1,5 +0,0 @@ -package me.rhunk.snapenhance.core.download.data - -enum class SplitMediaAssetType { - ORIGINAL, OVERLAY -} 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 @@ -1,6 +1,6 @@ package me.rhunk.snapenhance.core.event -import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.core.ModContext import kotlin.reflect.KClass abstract class Event { 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 @@ -4,19 +4,19 @@ import android.content.Intent import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams -import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.core.ModContext import me.rhunk.snapenhance.core.event.events.impl.* +import me.rhunk.snapenhance.core.manager.Manager +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.getObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField -import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper -import me.rhunk.snapenhance.data.wrapper.impl.Message -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.hook.hookConstructor -import me.rhunk.snapenhance.manager.Manager +import me.rhunk.snapenhance.core.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID class EventDispatcher( private val context: ModContext @@ -96,7 +96,9 @@ class EventDispatcher( androidContext = context.androidContext, intent = intent, action = action - ) + ).apply { + adapter = param + } ) { postHookEvent() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/AbstractHookEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/AbstractHookEvent.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.core.event.events import me.rhunk.snapenhance.core.event.Event -import me.rhunk.snapenhance.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookAdapter abstract class AbstractHookEvent : Event() { lateinit var adapter: HookAdapter diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BuildMessageEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BuildMessageEvent.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.core.event.events.impl import me.rhunk.snapenhance.core.event.Event -import me.rhunk.snapenhance.data.wrapper.impl.Message +import me.rhunk.snapenhance.core.wrapper.impl.Message class BuildMessageEvent( val message: Message diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/OnSnapInteractionEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/OnSnapInteractionEvent.kt @@ -1,7 +1,7 @@ package me.rhunk.snapenhance.core.event.events.impl import me.rhunk.snapenhance.core.event.events.AbstractHookEvent -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID class OnSnapInteractionEvent( val interactionType: String, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SendMessageWithContentEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/SendMessageWithContentEvent.kt @@ -1,10 +1,10 @@ package me.rhunk.snapenhance.core.event.events.impl import me.rhunk.snapenhance.core.event.events.AbstractHookEvent -import me.rhunk.snapenhance.data.wrapper.impl.MessageContent -import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations class SendMessageWithContentEvent( val destinations: MessageDestinations, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/BridgeFileFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/BridgeFileFeature.kt @@ -0,0 +1,54 @@ +package me.rhunk.snapenhance.core.features + +import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + +abstract class BridgeFileFeature(name: String, private val bridgeFileType: BridgeFileType, loadParams: Int) : Feature(name, loadParams) { + private val fileLines = mutableListOf<String>() + + protected fun readFile() { + val temporaryLines = mutableListOf<String>() + val fileData: ByteArray = context.bridgeClient.createAndReadFile(bridgeFileType, ByteArray(0)) + with(BufferedReader(InputStreamReader(ByteArrayInputStream(fileData), StandardCharsets.UTF_8))) { + var line = "" + while (readLine()?.also { line = it } != null) temporaryLines.add(line) + close() + } + fileLines.clear() + fileLines.addAll(temporaryLines) + } + + private fun updateFile() { + val sb = StringBuilder() + fileLines.forEach { + sb.append(it).append("\n") + } + context.bridgeClient.writeFile(bridgeFileType, sb.toString().toByteArray(Charsets.UTF_8)) + } + + protected fun exists(line: String) = fileLines.contains(line) + + protected fun toggle(line: String) { + if (exists(line)) fileLines.remove(line) else fileLines.add(line) + updateFile() + } + + protected fun setState(line: String, state: Boolean) { + if (state) { + if (!exists(line)) fileLines.add(line) + } else { + if (exists(line)) fileLines.remove(line) + } + updateFile() + } + + protected fun reload() = readFile() + + protected fun put(line: String) { + fileLines.add(line) + updateFile() + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/Feature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/Feature.kt @@ -0,0 +1,39 @@ +package me.rhunk.snapenhance.core.features + +import me.rhunk.snapenhance.core.ModContext + +abstract class Feature( + val featureKey: String, + val loadParams: Int = FeatureLoadParams.INIT_SYNC +) { + lateinit var context: ModContext + + /** + * called on the main thread when the mod initialize + */ + open fun init() {} + + /** + * called on a dedicated thread when the mod initialize + */ + open fun asyncInit() {} + + /** + * called when the Snapchat Activity is created + */ + open fun onActivityCreate() {} + + + /** + * called on a dedicated thread when the Snapchat Activity is created + */ + open fun asyncOnActivityCreate() {} + + protected fun findClass(name: String): Class<*> { + return context.androidContext.classLoader.loadClass(name) + } + + protected fun runOnUiThread(block: () -> Unit) { + context.runOnUiThread(block) + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureLoadParams.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureLoadParams.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.features + +object FeatureLoadParams { + const val NO_INIT = 0 + + const val INIT_SYNC = 0b0001 + const val ACTIVITY_CREATE_SYNC = 0b0010 + + const val INIT_ASYNC = 0b0100 + const val ACTIVITY_CREATE_ASYNC = 0b1000 +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/MessagingRuleFeature.kt @@ -0,0 +1,30 @@ +package me.rhunk.snapenhance.core.features + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState + +abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType, loadParams: Int = 0) : Feature(name, loadParams) { + + open fun getRuleState() = context.config.rules.getRuleState(ruleType) + + fun setState(conversationId: String, state: Boolean) { + context.bridgeClient.setRule( + context.database.getDMOtherParticipant(conversationId) ?: conversationId, + ruleType, + state + ) + } + + fun getState(conversationId: String) = + context.bridgeClient.getRules( + context.database.getDMOtherParticipant(conversationId) ?: conversationId + ).contains(ruleType) && getRuleState() != null + + fun canUseRule(conversationId: String): Boolean { + val state = getState(conversationId) + if (context.config.rules.getRuleState(ruleType) == RuleState.BLACKLIST) { + return !state + } + return state + } +}+ \ No newline at end of file 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 @@ -0,0 +1,81 @@ +package me.rhunk.snapenhance.core.features.impl + +import de.robv.android.xposed.XposedHelpers +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.ktx.setObjectField + +class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { + val propertyOverrides = mutableMapOf<String, Pair<(() -> Boolean), Any>>() + + fun overrideProperty(key: String, filter: () -> Boolean, value: Any) { + propertyOverrides[key] = Pair(filter, value) + } + + overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.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) + + 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("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") + } + + val compositeConfigurationProviderMappings = context.mappings.getMappedMap("CompositeConfigurationProvider") + val enumMappings = compositeConfigurationProviderMappings["enum"] as Map<*, *> + + findClass(compositeConfigurationProviderMappings["class"].toString()).hook( + compositeConfigurationProviderMappings["observeProperty"].toString(), + HookStage.BEFORE + ) { param -> + val enumData = param.arg<Any>(0) + val key = enumData.toString() + val setValue: (Any?) -> Unit = { value -> + val valueHolder = XposedHelpers.callMethod(enumData, enumMappings["getValue"].toString()) + valueHolder.setObjectField(enumMappings["defaultValueField"].toString(), value) + } + + propertyOverrides[key]?.let { (filter, value) -> + if (!filter()) return@let + setValue(value) + } + } + + findClass(compositeConfigurationProviderMappings["class"].toString()).hook( + compositeConfigurationProviderMappings["getProperty"].toString(), + HookStage.AFTER + ) { param -> + val propertyKey = param.arg<Any>(0).toString() + + propertyOverrides[propertyKey]?.let { (filter, value) -> + if (!filter()) return@let + param.setResult(value) + } + } + + arrayOf("getBoolean", "getInt", "getLong", "getFloat", "getString").forEach { methodName -> + findClass("android.app.SharedPreferencesImpl").hook( + methodName, + HookStage.BEFORE + ) { param -> + val key = param.argNullable<Any>(0).toString() + propertyOverrides[key]?.let { (filter, value) -> + if (!filter()) return@let + param.setResult(value) + } + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,43 @@ +package me.rhunk.snapenhance.core.features.impl + +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.SocialScope +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams + +class ScopeSync : Feature("Scope Sync", loadParams = FeatureLoadParams.INIT_SYNC) { + companion object { + private const val DELAY_BEFORE_SYNC = 2000L + } + + private val updateJobs = mutableMapOf<String, Job>() + + private fun sync(conversationId: String) { + context.database.getDMOtherParticipant(conversationId)?.also { participant -> + context.bridgeClient.triggerSync(SocialScope.FRIEND, participant) + } ?: run { + context.bridgeClient.triggerSync(SocialScope.GROUP, conversationId) + } + } + + override fun init() { + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + if (event.messageContent.contentType != ContentType.SNAP) return@subscribe + + event.addCallbackResult("onSuccess") { + event.destinations.conversations.map { it.toString() }.forEach { conversationId -> + updateJobs[conversationId]?.also { it.cancel() } + + updateJobs[conversationId] = (context.coroutineScope.launch { + delay(DELAY_BEFORE_SYNC) + sync(conversationId) + }) + } + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,719 @@ +package me.rhunk.snapenhance.core.features.impl.downloader + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.text.InputType +import android.view.Gravity +import android.view.ViewGroup.MarginLayoutParams +import android.widget.EditText +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.common.data.FileType +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.download.DownloadMediaType +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.InputMedia +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.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver +import me.rhunk.snapenhance.core.DownloadManagerClient +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +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.Hooker +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.util.media.PreviewUtils +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.media.MediaInfo +import me.rhunk.snapenhance.core.wrapper.impl.media.dash.LongformVideoPlaylistItem +import me.rhunk.snapenhance.core.wrapper.impl.media.dash.SnapPlaylistItem +import me.rhunk.snapenhance.core.wrapper.impl.media.opera.Layer +import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap +import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair +import java.io.ByteArrayInputStream +import java.nio.file.Paths +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.coroutines.suspendCoroutine +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +private fun String.sanitizeForPath(): String { + return this.replace(" ", "_") + .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<SplitMediaAssetType, MediaInfo>? = null + private var lastSeenMapParams: ParamMap? = null + + private val translations by lazy { + context.translation.getCategory("download_processor") + } + + private fun provideDownloadManagerClient( + mediaIdentifier: String, + mediaAuthor: String, + downloadSource: MediaDownloadSource, + 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 + if (downloadLogging.contains("started")) { + context.shortToast(translations["download_started_toast"]) + } + + val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) + + return DownloadManagerClient( + context = context, + metadata = DownloadMetadata( + mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { + generatedHash + } else null, + mediaAuthor = mediaAuthor, + downloadSource = downloadSource.key, + iconUrl = iconUrl, + outputPath = outputPath + ), + callback = object: DownloadCallback.Stub() { + override fun onSuccess(outputFile: String) { + if (!downloadLogging.contains("success")) return + context.log.verbose("onSuccess: outputFile=$outputFile") + context.shortToast(translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) + } + + override fun onProgress(message: String) { + if (!downloadLogging.contains("progress")) return + context.log.verbose("onProgress: message=$message") + context.shortToast(message) + } + + override fun onFailure(message: String, throwable: String?) { + if (!downloadLogging.contains("failure")) return + context.log.verbose("onFailure: message=$message, throwable=$throwable") + throwable?.let { + context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) + return + } + context.shortToast(message) + } + } + ) + } + + + private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): 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 finalPath = StringBuilder() + + fun appendFileName(string: String) { + if (finalPath.isEmpty() || finalPath.endsWith("/")) { + finalPath.append(string) + } else { + finalPath.append("_").append(string) + } + } + + 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) + } + if (pathFormat.contains("append_source")) { + appendFileName(downloadSource.pathName) + } + if (pathFormat.contains("append_username")) { + appendFileName(sanitizedMediaAuthor) + } + if (pathFormat.contains("append_date_time")) { + appendFileName(currentDateTime) + } + + if (finalPath.isEmpty()) finalPath.append(hexHash) + + return finalPath.toString() + } + + /* + * Download the last seen media + */ + fun downloadLastOperaMediaAsync() { + if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return + context.executeAsync { + handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true) + } + } + + fun showLastOperaDebugMediaInfo() { + if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return + + context.runOnUiThread { + val mediaInfoText = lastSeenMapParams?.concurrentHashMap?.map { (key, value) -> + val transformedValue = value.let { + if (it::class.java == SnapEnhance.classCache.snapUUID) { + SnapUUID(it).toString() + } + it + } + "- $key: $transformedValue" + }?.joinToString("\n") ?: "No media info found" + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle("Debug Media Info") + setView(EditText(context).apply { + inputType = InputType.TYPE_NULL + setTextIsSelectable(true) + isSingleLine = false + textSize = 12f + setPadding(20, 20, 20, 20) + setText(mediaInfoText) + setTextColor(context.resources.getColor(android.R.color.white, context.theme)) + }) + setNeutralButton("Copy") { _, _ -> + this@MediaDownloader.context.copyToClipboard(mediaInfoText) + } + setPositiveButton("Download") { _, _ -> + downloadLastOperaMediaAsync() + } + setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } + }.show() + } + } + + private fun handleLocalReferences(path: String) = runBlocking { + Uri.parse(path).let { uri -> + if (uri.scheme == "file") { + return@let suspendCoroutine<String> { continuation -> + context.httpServer.ensureServerStarted { + val file = Paths.get(uri.path).toFile() + val url = putDownloadableContent(file.inputStream(), file.length()) + continuation.resumeWith(Result.success(url)) + } + } + } + path + } + } + + private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) { + if (mediaInfoMap.isEmpty()) return + + val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! + val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) + + mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> + val overlayReference = handleLocalReferences(overlay.uri) + + downloadManagerClient.downloadMediaWithOverlay( + original = InputMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ), + overlay = InputMedia( + overlayReference, + DownloadMediaType.fromUri(Uri.parse(overlayReference)), + overlay.encryption?.toKeyPair(), + isOverlay = true + ) + ) + return + } + + downloadManagerClient.downloadSingleMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ) + } + + /** + * Handles the media from the opera viewer + * + * @param paramMap the parameters from the opera viewer + * @param mediaInfoMap the media info map + * @param forceDownload if the media should be downloaded + */ + private fun handleOperaMedia( + paramMap: ParamMap, + mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, + forceDownload: Boolean + ) { + //messages + paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || canAutoDownload("friend_snaps") }?.let { id -> + val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() + val conversationMessage = context.database.getConversationMessageFromId(messageId)!! + + val senderId = conversationMessage.senderId!! + val conversationId = conversationMessage.clientConversationId!! + + if (!forceDownload && !canUseRule(senderId)) { + return + } + + if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return + + val author = context.database.getFriendInfo(senderId) ?: return + val authorUsername = author.usernameForSorting!! + val mediaId = paramMap["MEDIA_ID"]?.toString()?.split("-")?.getOrNull(1) ?: "" + + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", + mediaAuthor = authorUsername, + downloadSource = MediaDownloadSource.CHAT_MEDIA, + friendInfo = author + ), mediaInfoMap) + + return + } + + //private stories + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { + forceDownload || canAutoDownload("friend_stories") + }?.let { playlistGroup -> + val playlistGroupString = playlistGroup.toString() + + val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: if (playlistGroupString.contains("storyUserId=")) { + (playlistGroupString.indexOf("storyUserId=") + 12).let { + playlistGroupString.substring(it, playlistGroupString.indexOf(",", it)) + } + } else { + //story replies + val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } + ?.invoke(playlistGroup)?.toString() + ?.split(":")?.getOrNull(2) ?: return@let + + val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let + val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let + + conversationParticipants.firstOrNull { it != conversationMessage.senderId } + } + + val author = context.database.getFriendInfo( + if (storyUserId == null || storyUserId == "null") + context.database.myUserId + else storyUserId + ) ?: throw Exception("Friend not found in database") + val authorName = author.usernameForSorting!! + + 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(), + mediaAuthor = authorName, + downloadSource = MediaDownloadSource.STORY, + friendInfo = author + ), mediaInfoMap) + return + } + + val snapSource = paramMap["SNAP_SOURCE"].toString() + + //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() + + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = paramMap["SNAP_ID"].toString(), + mediaAuthor = userDisplayName, + downloadSource = MediaDownloadSource.PUBLIC_STORY, + ), mediaInfoMap) + return + } + + //spotlight + if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { + downloadOperaMedia(provideDownloadManagerClient( + mediaIdentifier = paramMap["SNAP_ID"].toString(), + downloadSource = MediaDownloadSource.SPOTLIGHT, + mediaAuthor = paramMap["TIME_STAMP"].toString() + ), mediaInfoMap) + return + } + + //stories with mpeg dash media + 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 + } + + fun prettyPrintTime(time: Long): String { + val seconds = time / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + return "${hours % 24}:${minutes % 60}:${seconds % 60}" + } + + 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")) + + urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> + it.substring(validIndex) + } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" + } + + context.runOnUiThread { + val selectedChapters = mutableListOf<Int>() + 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) + } + } + 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") { _, _ -> + val groups = mutableListOf<MutableList<SnapChapterInfo>>() + var currentGroup = mutableListOf<SnapChapterInfo>() + 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() + } + } + } + + private fun canAutoDownload(keyFilter: String? = null): Boolean { + val options by context.config.downloader.autoDownloadSources + return options.any { keyFilter == null || it.contains(keyFilter, true) } + } + + override fun asyncOnActivityCreate() { + val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "class") + + val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> + + val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString() + if (viewState != "FULLY_DISPLAYED") { + return@onOperaViewStateCallback + } + val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + + if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) + return@onOperaViewStateCallback + + val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() + val isVideo = mediaParamMap.containsKey("video_media_info_list") + + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( + (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! + ) + + if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { + mediaInfoMap[SplitMediaAssetType.OVERLAY] = + MediaInfo(mediaParamMap["overlay_image_media_info"]!!) + } + lastSeenMapParams = mediaParamMap + lastSeenMediaInfoMap = mediaInfoMap + + if (!canAutoDownload()) return@onOperaViewStateCallback + + context.executeAsync { + runCatching { + handleOperaMedia(mediaParamMap, mediaInfoMap, false) + }.onFailure { + context.log.error("Failed to handle opera media", it) + context.longToast(it.message) + } + } + } + + arrayOf("onDisplayStateChange", "onDisplayStateChangeGesture").forEach { methodName -> + Hooker.hook( + operaViewerControllerClass, + context.mappings.getMappedValue("OperaPageViewController", methodName), + HookStage.AFTER, onOperaViewStateCallback + ) + } + } + + private fun downloadMessageAttachments( + friendInfo: FriendInfo, + message: ConversationMessage, + authorName: String, + attachments: List<DecodedAttachment> + ) { + //TODO: stickers + attachments.forEach { attachment -> + runCatching { + provideDownloadManagerClient( + mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.mediaUniqueId}", + downloadSource = MediaDownloadSource.CHAT_MEDIA, + mediaAuthor = authorName, + friendInfo = friendInfo + ).downloadSingleMedia( + mediaData = attachment.mediaUrlKey!!, + 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) { + val messageLogger = context.feature(MessageLogger::class) + val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") + + //get the message author + val friendInfo: FriendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") + val authorName = friendInfo.usernameForSorting!! + + val decodedAttachments = messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { + MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) + } ?: MessageDecoder.decode( + protoReader = ProtoReader(message.messageContent!!) + ) + + if (decodedAttachments.isEmpty()) { + context.shortToast(translations["no_attachments_toast"]) + return + } + + if (!isPreview) { + if (decodedAttachments.size == 1 || + context.mainActivity == null // we can't show alert dialogs when it downloads from a notification, so it downloads the first one + ) { + downloadMessageAttachments(friendInfo, message, authorName, + listOf(decodedAttachments.first()) + ) + return + } + + runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { + val selectedAttachments = mutableListOf<Int>().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() + } + + return + } + + runBlocking { + val firstAttachment = decodedAttachments.first() + + val previewCoroutine = async { + val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(Base64.decode(firstAttachment.mediaUrlKey!!), decryptionCallback = { + firstAttachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }) ?: return@async null + + val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() + + MediaDownloaderHelper.getSplitElements(ByteArrayInputStream(downloadedMedia)) { + type, inputStream -> + downloadedMediaList[type] = inputStream.readBytes() + } + + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + + var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + + if (bitmap == null) { + context.shortToast(translations["failed_to_create_preview_toast"]) + return@async null + } + + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + bitmap + } + + 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() + } + + 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) + window?.setLayout( + context.resources.displayMetrics.widthPixels, + context.resources.displayMetrics.heightPixels + ) + } + previewCoroutine.start() + } + } + } + } + + fun downloadProfilePicture(url: String, author: String) { + provideDownloadManagerClient( + mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), + mediaAuthor = author, + downloadSource = MediaDownloadSource.PROFILE_PICTURE + ).downloadSingleMedia( + url, + DownloadMediaType.REMOTE_MEDIA + ) + } + + /** + * Called when a message is focused in chat + */ + fun onMessageActionMenu(isPreviewMode: Boolean) { + val messaging = context.feature(Messaging::class) + if (messaging.openedConversationUUID == null) return + downloadMessageId(messaging.lastFocusedMessageId, isPreviewMode) + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt @@ -0,0 +1,80 @@ +package me.rhunk.snapenhance.core.features.impl.downloader + +import android.annotation.SuppressLint +import android.widget.Button +import android.widget.RelativeLayout +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +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.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +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 + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe + + event.parent.addView(Button(event.parent.context).apply { + 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) + } + setOnClickListener { + ViewAppearanceHelper.newAlertDialogBuilder( + this@ProfilePictureDownloader.context.mainActivity!! + ).apply { + setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"]) + val choices = mutableMapOf<String, String>() + backgroundUrl?.let { choices["avatar_option"] = it } + avatarUrl?.let { choices["background_option"] = it } + + 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), + friendUsername!! + ) + }.onFailure { + this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it) + } + } + }.show() + } + }) + } + + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!event.url.endsWith("/rpc/getPublicProfile")) return@subscribe + Hooker.ephemeralHookObjectMethod(event.callback::class.java, event.callback, "onSucceeded", HookStage.BEFORE) { methodParams -> + val content = methodParams.arg<ByteBuffer>(2).run { + ByteArray(capacity()).also { + get(it) + position(0) + } + } + + ProtoReader(content).followPath(1, 1, 2) { + friendUsername = getString(2) ?: return@followPath + followPath(4) { + backgroundUrl = getString(2) + avatarUrl = getString(100) + } + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentInfo.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.core.features.impl.downloader.decoder + +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair + +data class BitmojiSticker( + val reference: String, +) : AttachmentInfo() + +open class AttachmentInfo( + val encryption: MediaEncryptionKeyPair? = null, + val resolution: Pair<Int, Int>? = 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/core/features/impl/downloader/decoder/AttachmentType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.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/core/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt @@ -0,0 +1,191 @@ +package me.rhunk.snapenhance.core.features.impl.downloader.decoder + +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.data.download.toKeyPair +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DecodedAttachment( + val mediaUrlKey: String?, + val type: AttachmentType, + val attachmentInfo: AttachmentInfo? +) { + @OptIn(ExperimentalEncodingApi::class) + val mediaUniqueId: String? by lazy { + runCatching { Base64.UrlSafe.decode(mediaUrlKey.toString()) }.getOrNull()?.let { ProtoReader(it).getString(2, 2) } + } +} + +@OptIn(ExperimentalEncodingApi::class) +object MessageDecoder { + private val gson = GsonBuilder().create() + + 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 + ) + } + + @OptIn(ExperimentalEncodingApi::class) + fun getEncodedMediaReferences(messageContent: JsonElement): List<String> { + return getMediaReferences(messageContent).map { reference -> + Base64.UrlSafe.encode( + reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + ) + } + .toList() + } + + fun getMediaReferences(messageContent: JsonElement): List<JsonElement> { + return messageContent.asJsonObject.getAsJsonArray("mRemoteMediaReferences") + .asSequence() + .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } + .flatten() + .sortedBy { + it.asJsonObject["mMediaListId"].asLong + }.toList() + } + + + fun decode(messageContent: MessageContent): List<DecodedAttachment> { + return decode( + ProtoReader(messageContent.content), + customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) + ) + } + + fun decode(messageContent: JsonObject): List<DecodedAttachment> { + return decode( + ProtoReader(messageContent.getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray()), + customMediaReferences = getEncodedMediaReferences(messageContent) + ) + } + + fun decode( + protoReader: ProtoReader, + customMediaReferences: List<String>? = null // when customReferences is null it means that the message is from arroyo database + ): List<DecodedAttachment> { + val decodedAttachment = mutableListOf<DecodedAttachment>() + val mediaReferences = mutableListOf<String>() + customMediaReferences?.let { mediaReferences.addAll(it) } + var mediaKeyIndex = 0 + + fun decodeMedia(type: AttachmentType, protoReader: ProtoReader) { + decodedAttachment.add( + DecodedAttachment( + mediaUrlKey = 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( + mediaUrlKey = 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( + mediaUrlKey = 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 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 @@ -0,0 +1,55 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +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 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<Any>(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/core/features/impl/experiments/AmoledDarkMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AmoledDarkMode.kt @@ -0,0 +1,50 @@ +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 + +class AmoledDarkMode : Feature("Amoled Dark Mode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + @SuppressLint("DiscouragedApi") + override fun onActivityCreate() { + if (!context.config.userInterface.amoledDarkMode.get()) return + val attributeCache = mutableMapOf<String, Int>() + + 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 } + } + + context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook( + HookStage.AFTER) { param -> + val array = param.arg<IntArray>(0) + val result = param.getResult() as TypedArray + + fun ephemeralHook(methodName: String, content: Any) { + Hooker.ephemeralHookObjectMethod(result::class.java, result, methodName, HookStage.BEFORE) { + it.setResult(content) + } + } + + when (array[0]) { + getAttribute("sigColorTextPrimary") -> { + ephemeralHook("getColor", 0xFFFFFFFF.toInt()) + } + getAttribute("sigColorBackgroundMain"), + getAttribute("sigColorBackgroundSurface") -> { + ephemeralHook("getColor", 0xFF000000.toInt()) + } + getAttribute("actionSheetBackgroundDrawable"), + getAttribute("actionSheetRoundedBackgroundDrawable") -> { + ephemeralHook("getDrawable", ColorDrawable(0xFF000000.toInt())) + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppPasscode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/AppPasscode.kt @@ -0,0 +1,106 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper + +//TODO: fingerprint unlock +class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private var isLocked = false + + private fun setActivityVisibility(isVisible: Boolean) { + context.mainActivity?.let { + it.window.attributes = it.window.attributes.apply { alpha = if (isVisible) 1.0F else 0.0F } + } + } + + fun lock() { + if (isLocked) return + isLocked = true + val passcode by context.config.experimental.appPasscode.also { + if (it.getNullable()?.isEmpty() != false) return + } + val isDigitPasscode = passcode.all { it.isDigit() } + + val mainActivity = context.mainActivity!! + setActivityVisibility(false) + + val prompt = ViewAppearanceHelper.newAlertDialogBuilder(mainActivity) + val createPrompt = { + val alertDialog = prompt.create() + val textView = EditText(mainActivity) + textView.setSingleLine() + textView.inputType = if (isDigitPasscode) { + (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD) + } else { + (InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) + } + textView.hint = "Code :" + textView.setPadding(100, 100, 100, 100) + + textView.addTextChangedListener(object: TextWatcher { + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (s.contentEquals(passcode)) { + alertDialog.dismiss() + isLocked = false + setActivityVisibility(true) + } + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun afterTextChanged(s: Editable?) {} + }) + + alertDialog.setView(textView) + + textView.viewTreeObserver.addOnWindowFocusChangeListener { hasFocus -> + if (!hasFocus) return@addOnWindowFocusChangeListener + val imm = mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT) + } + + alertDialog.window?.let { + it.attributes.verticalMargin = -0.18F + } + + alertDialog.show() + textView.requestFocus() + } + + prompt.setOnCancelListener { + createPrompt() + } + + createPrompt() + } + + @SuppressLint("MissingPermission") + override fun onActivityCreate() { + if (!context.database.hasArroyo()) return + + context.runOnUiThread { + lock() + } + + if (!context.config.experimental.appLockOnResume.get()) return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.mainActivity?.registerActivityLifecycleCallbacks(object: android.app.Application.ActivityLifecycleCallbacks { + override fun onActivityPaused(activity: android.app.Activity) { lock() } + override fun onActivityResumed(activity: android.app.Activity) {} + override fun onActivityStarted(activity: android.app.Activity) {} + override fun onActivityDestroyed(activity: android.app.Activity) {} + override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} + override fun onActivityStopped(activity: android.app.Activity) {} + override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} + }) + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +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 DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + if (context.config.experimental.spoof.globalState != true) return + + 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 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) + context.log.verbose("Fingerprint spoofed to $fingerprint") + } + Hooker.hook(fingerprintClass, "deriveFingerprint", HookStage.BEFORE) { hookAdapter -> + hookAdapter.setResult(fingerprint) + context.log.verbose("Fingerprint spoofed to $fingerprint") + } + } + + //ANDROID ID + if (androidId.isNotEmpty()) { + Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> + if(hookAdapter.args()[1] == "android_id") { + hookAdapter.setResult(androidId) + context.log.verbose("Android ID spoofed to $androidId") + } + } + } + + //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) + } + } + + //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 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 @@ -0,0 +1,500 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import android.widget.TextView +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +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.getObjectField +import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import java.security.MessageDigest +import kotlin.random.Random + +class EndToEndEncryption : MessagingRuleFeature( + "EndToEndEncryption", + MessagingRuleType.E2E_ENCRYPTION, + loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_SYNC or FeatureLoadParams.INIT_ASYNC +) { + private val isEnabled get() = context.config.experimental.e2eEncryption.globalState == true + private val e2eeInterface by lazy { context.bridgeClient.getE2eeInterface() } + + companion object { + const val REQUEST_PK_MESSAGE_ID = 1 + const val RESPONSE_SK_MESSAGE_ID = 2 + const val ENCRYPTED_MESSAGE_ID = 3 + } + + private val decryptedMessageCache = EvictingMap<Long, Pair<ContentType, ByteArray>>(100) + + private val pkRequests = mutableMapOf<Long, ByteArray>() + private val secretResponses = mutableMapOf<Long, ByteArray>() + private val encryptedMessages = mutableListOf<Long>() + + private fun getE2EParticipants(conversationId: String): List<String> { + return context.database.getConversationParticipants(conversationId)?.filter { friendId -> e2eeInterface.friendKeyExists(friendId) } ?: emptyList() + } + + private fun askForKeys(conversationId: String) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + + val publicKey = e2eeInterface.createKeyExchange(friendId) ?: run { + context.longToast("Can't create key exchange for friendId $friendId") + return + } + + context.log.verbose("created publicKey: ${publicKey.contentToString()}") + + sendCustomMessage(conversationId, REQUEST_PK_MESSAGE_ID) { + addBuffer(2, publicKey) + } + } + + private fun sendCustomMessage(conversationId: String, messageId: Int, message: ProtoWriter.() -> Unit) { + context.messageSender.sendCustomChatMessage( + listOf(SnapUUID.fromString(conversationId)), + ContentType.CHAT, + message = { + from(2) { + from(1) { + addVarInt(1, messageId) + addBuffer(2, ProtoWriter().apply(message).toByteArray()) + } + } + } + ) + } + + private fun warnKeyOverwrite(friendId: String, block: () -> Unit) { + if (!e2eeInterface.friendKeyExists(friendId)) { + block() + return + } + + context.mainActivity?.runOnUiThread { + val mainActivity = context.mainActivity ?: return@runOnUiThread + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle("End-to-end encryption") + setMessage("WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?") + setPositiveButton("Yes") { _, _ -> + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setTitle("End-to-end encryption") + setMessage("Are you REALLY sure you want to continue? This is your last chance to back out.") + setNeutralButton("Yes") { _, _ -> block() } + setPositiveButton("No") { _, _ -> } + }.show() + } + setNegativeButton("No") { _, _ -> } + }.show() + } + } + + private fun handlePublicKeyRequest(conversationId: String, publicKey: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + val encapsulatedSecret = e2eeInterface.acceptPairingRequest(friendId, publicKey) + if (encapsulatedSecret == null) { + context.longToast("Failed to accept public key") + return@warnKeyOverwrite + } + context.longToast("Public key successfully accepted") + + sendCustomMessage(conversationId, RESPONSE_SK_MESSAGE_ID) { + addBuffer(2, encapsulatedSecret) + } + } + } + + private fun handleSecretResponse(conversationId: String, secret: ByteArray) { + val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { + context.longToast("Can't find friendId for conversationId $conversationId") + return + } + warnKeyOverwrite(friendId) { + context.log.verbose("handleSecretResponse, secret = $secret") + val result = e2eeInterface.acceptPairingResponse(friendId, secret) + if (!result) { + context.longToast("Failed to accept secret") + return@warnKeyOverwrite + } + context.longToast("Done! You can now exchange encrypted messages with this friend.") + } + } + + private fun openManagementPopup() { + val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return + val friendId = context.database.getDMOtherParticipant(conversationId) + + if (friendId == null) { + context.shortToast("This menu is only available in direct messages.") + return + } + + val actions = listOf( + "Initiate a new shared secret", + "Show shared key fingerprint" + ) + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + setTitle("End-to-end encryption") + setItems(actions.toTypedArray()) { _, which -> + when (which) { + 0 -> { + warnKeyOverwrite(friendId) { + askForKeys(conversationId) + } + } + 1 -> { + val fingerprint = e2eeInterface.getSecretFingerprint(friendId) + ViewAppearanceHelper.newAlertDialogBuilder(context).apply { + setTitle("End-to-end encryption") + setMessage("Your fingerprint is:\n\n$fingerprint\n\nMake sure to check if it matches your friend's fingerprint!") + setPositiveButton("OK") { _, _ -> } + }.show() + } + } + } + setPositiveButton("OK") { _, _ -> } + }.show() + } + + @SuppressLint("SetTextI18n", "DiscouragedApi") + override fun onActivityCreate() { + if (!isEnabled) return + // add button to input bar + context.event.subscribe(AddViewEvent::class) { param -> + if (param.view.toString().contains("default_input_bar")) { + (param.view as ViewGroup).addView(TextView(param.view.context).apply { + layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) + setOnClickListener { openManagementPopup() } + setPadding(20, 20, 20, 20) + textSize = 23f + text = "\uD83D\uDD12" + }) + } + } + + val encryptedMessageIndicator by context.config.experimental.e2eEncryption.encryptedMessageIndicator + + // hook view binder to add special buttons + val receivePublicKeyTag = Random.nextLong().toString(16) + val receiveSecretTag = Random.nextLong().toString(16) + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + val viewGroup = event.view as ViewGroup + + viewGroup.findViewWithTag<View>(receiveSecretTag)?.also { + viewGroup.removeView(it) + } + + viewGroup.findViewWithTag<View>(receivePublicKeyTag)?.also { + viewGroup.removeView(it) + } + + if (encryptedMessageIndicator) { + viewGroup.removeForegroundDrawable("encryptedMessage") + + if (encryptedMessages.contains(messageId.toLong())) { + viewGroup.addForegroundDrawable("encryptedMessage", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + paint.textSize = 20f + canvas.drawText("\uD83D\uDD12", 0f, canvas.height / 2f, paint) + } + })) + } + } + + secretResponses[messageId.toLong()]?.also { secret -> + viewGroup.addView(Button(context.mainActivity!!).apply { + text = "Accept secret" + tag = receiveSecretTag + setOnClickListener { + handleSecretResponse(conversationId, secret) + } + }) + } + + pkRequests[messageId.toLong()]?.also { publicKey -> + viewGroup.addView(Button(context.mainActivity!!).apply { + text = "Receive public key" + tag = receivePublicKeyTag + setOnClickListener { + handlePublicKeyRequest(conversationId, publicKey) + } + }) + } + } + } + } + + private fun fixContentType(contentType: ContentType?, message: ProtoReader) + = ContentType.fromMessageContainer(message) ?: contentType + + private fun hashParticipantId(participantId: String, salt: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").apply { + update(participantId.toByteArray()) + update(salt) + }.digest() + } + + private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent) { + if (messageContent.contentType != ContentType.STATUS && decryptedMessageCache.containsKey(messageId)) { + val (contentType, buffer) = decryptedMessageCache[messageId]!! + messageContent.contentType = contentType + messageContent.content = buffer + return + } + + val reader = ProtoReader(messageContent.content) + messageContent.contentType = fixContentType(messageContent.contentType!!, reader) + + fun setMessageContent(buffer: ByteArray) { + messageContent.content = buffer + messageContent.contentType = fixContentType(messageContent.contentType, ProtoReader(buffer)) + decryptedMessageCache[messageId] = messageContent.contentType!! to buffer + } + + fun replaceMessageText(text: String) { + messageContent.content = ProtoWriter().apply { + from(2) { + addString(1, text) + } + }.toByteArray() + } + + // decrypt messages + reader.followPath(2, 1) { + val messageTypeId = getVarInt(1)?.toInt() ?: return@followPath + val isMe = context.database.myUserId == senderId + val conversationParticipants by lazy { + getE2EParticipants(conversationId) + } + + if (messageTypeId == ENCRYPTED_MESSAGE_ID) { + runCatching { + eachBuffer(2) { + val participantIdHash = getByteArray(1) ?: return@eachBuffer + val iv = getByteArray(2) ?: return@eachBuffer + val ciphertext = getByteArray(3) ?: return@eachBuffer + + if (isMe) { + if (conversationParticipants.isEmpty()) return@eachBuffer + val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer + setMessageContent( + e2eeInterface.decryptMessage(participantId, ciphertext, iv) + ) + encryptedMessages.add(messageId) + return@eachBuffer + } + + if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer + + setMessageContent( + e2eeInterface.decryptMessage(senderId, ciphertext, iv) + ) + encryptedMessages.add(messageId) + } + }.onFailure { + context.log.error("Failed to decrypt message id: $messageId", it) + messageContent.contentType = ContentType.CHAT + messageContent.content = ProtoWriter().apply { + from(2) { + addString(1, "Failed to decrypt message, id=$messageId. Check logcat for more details.") + } + }.toByteArray() + } + + return@followPath + } + + val payload = getByteArray(2, 2) ?: return@followPath + + if (senderId == context.database.myUserId) { + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + replaceMessageText("[Key exchange request]") + } + RESPONSE_SK_MESSAGE_ID -> { + replaceMessageText("[Key exchange response]") + } + } + return@followPath + } + + when (messageTypeId) { + REQUEST_PK_MESSAGE_ID -> { + pkRequests[messageId] = payload + replaceMessageText("You just received a public key request. Click below to accept it.") + } + RESPONSE_SK_MESSAGE_ID -> { + secretResponses[messageId] = payload + replaceMessageText("Your friend just accepted your public key. Click below to accept the secret.") + } + } + } + } + + override fun asyncInit() { + if (!isEnabled) return + val forceMessageEncryption by context.config.experimental.e2eEncryption.forceMessageEncryption + + // trick to disable fidelius encryption + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + val messageContent = event.messageContent + val destinations = event.destinations + + val e2eeConversations = destinations.conversations.filter { getState(it.toString()) } + + if (e2eeConversations.isEmpty()) return@subscribe + + if (e2eeConversations.size != destinations.conversations.size) { + if (!forceMessageEncryption) return@subscribe + context.longToast("You can't send encrypted content to both encrypted and unencrypted conversations!") + event.canceled = true + return@subscribe + } + + event.addInvokeLater { + if (messageContent.contentType == ContentType.SNAP) { + messageContent.contentType = ContentType.EXTERNAL_MEDIA + } + + if (messageContent.contentType == ContentType.CHAT) { + messageContent.contentType = ContentType.SHARE + } + } + } + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + val protoReader = ProtoReader(event.buffer) + + val conversationIds = mutableListOf<SnapUUID>() + protoReader.eachBuffer(3) { + conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer)) + } + + if (conversationIds.any { !getState(it.toString()) }) { + context.log.debug("Skipping encryption for conversation ids: ${conversationIds.joinToString(", ")}") + return@subscribe + } + + val participantsIds = conversationIds.map { getE2EParticipants(it.toString()) }.flatten().distinct() + + if (participantsIds.isEmpty()) { + context.longToast("You don't have any friends in this conversation to encrypt messages with!") + return@subscribe + } + val messageReader = protoReader.followPath(4) ?: return@subscribe + + if (messageReader.getVarInt(4, 2, 1, 1) != null) { + return@subscribe + } + + event.buffer = ProtoEditor(event.buffer).apply { + val contentType = fixContentType( + ContentType.fromId(messageReader.getVarInt(2)?.toInt() ?: -1), + messageReader.followPath(4) ?: return@apply + ) ?: return@apply + val messageContent = messageReader.getByteArray(4) ?: return@apply + + runCatching { + edit(4) { + //set message content type + remove(2) + addVarInt(2, contentType.id) + + //set encrypted content + remove(4) + add(4) { + from(2) { + from(1) { + addVarInt(1, ENCRYPTED_MESSAGE_ID) + participantsIds.forEach { participantId -> + val encryptedMessage = e2eeInterface.encryptMessage(participantId, + messageContent + ) ?: run { + context.log.error("Failed to encrypt message for $participantId") + return@forEach + } + context.log.debug("encrypted message size = ${encryptedMessage.ciphertext.size} for $participantId") + from(2) { + // participantId is hashed with iv to prevent leaking it when sending to multiple conversations + addBuffer(1, hashParticipantId(participantId, encryptedMessage.iv)) + addBuffer(2, encryptedMessage.iv) + addBuffer(3, encryptedMessage.ciphertext) + } + } + } + } + } + } + }.onFailure { + event.canceled = true + context.log.error("Failed to encrypt message", it) + context.longToast("Failed to encrypt message! Check logcat for more details.") + } + }.toByteArray() + } + } + + override fun init() { + if (!isEnabled) return + + context.event.subscribe(BuildMessageEvent::class, priority = 0) { event -> + val message = event.message + val conversationId = message.messageDescriptor.conversationId.toString() + messageHook( + conversationId = conversationId, + messageId = message.messageDescriptor.messageId, + senderId = message.senderId.toString(), + messageContent = message.messageContent + ) + + message.messageContent.instanceNonNull() + .getObjectField("mQuotedMessage") + ?.getObjectField("mContent") + ?.also { quotedMessage -> + messageHook( + conversationId = conversationId, + messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also, + senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(), + messageContent = MessageContent(quotedMessage) + ) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/InfiniteStoryBoost.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/InfiniteStoryBoost.kt @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +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 + +class InfiniteStoryBoost : Feature("InfiniteStoryBoost", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + val storyBoostStateClass = context.mappings.getMappedClass("StoryBoostStateClass") + + storyBoostStateClass.hookConstructor(HookStage.BEFORE, { + context.config.experimental.infiniteStoryBoost.get() + }) { param -> + val startTimeMillis = param.arg<Long>(1) + //reset timestamp if it's more than 24 hours + if (System.currentTimeMillis() - startTimeMillis > 86400000) { + param.setArg(1, 0) + param.setArg(2, 0) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MeoPasscodeBypass.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MeoPasscodeBypass.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 +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker + +class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + val bcrypt = context.mappings.getMappedMap("BCrypt") + + Hooker.hook( + context.androidContext.classLoader.loadClass(bcrypt["class"].toString()), + bcrypt["hashMethod"].toString(), + HookStage.BEFORE, + { context.config.experimental.meoPasscodeBypass.get() }, + ) { param -> + //set the hash to the result of the method + param.setResult(param.arg(1)) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/NoFriendScoreDelay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/NoFriendScoreDelay.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +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 java.lang.reflect.Constructor + +class NoFriendScoreDelay : Feature("NoFriendScoreDelay", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + if (!context.config.experimental.noFriendScoreDelay.get()) return + val scoreUpdateClass = context.mappings.getMappedClass("ScoreUpdate") + + scoreUpdateClass.hookConstructor(HookStage.BEFORE) { param -> + val constructor = param.method() as Constructor<*> + if (constructor.parameterTypes.size < 3 || constructor.parameterTypes[3] != java.util.Collection::class.java) return@hookConstructor + param.setArg(2, 0L) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/UnlimitedMultiSnap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/UnlimitedMultiSnap.kt @@ -0,0 +1,24 @@ +package me.rhunk.snapenhance.core.features.impl.experiments + +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.setObjectField + +class UnlimitedMultiSnap : Feature("UnlimitedMultiSnap", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + android.util.Pair::class.java.hookConstructor(HookStage.AFTER, { + context.config.experimental.unlimitedMultiSnap.get() + }) { param -> + val first = param.arg<Any>(0) + val second = param.arg<Any>(1) + if ( + first == true && // isOverTheLimit + second == 8 // limit + ) { + param.thisObject<Any>().setObjectField("first", false) + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.os.Build +import android.os.FileObserver +import com.google.gson.JsonParser +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +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.setObjectField +import java.io.File + +class BypassVideoLengthRestriction : + Feature("BypassVideoLengthRestriction", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + private lateinit var fileObserver: FileObserver + + override fun asyncOnActivityCreate() { + val mode = context.config.global.bypassVideoLengthRestriction.getNullable() + + if (mode == "single") { + //fix black videos when story is posted + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val postedStorySnapFolder = + File(context.androidContext.filesDir, "file_manager/posted_story_snap") + + fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) { + override fun onEvent(event: Int, path: String?) { + if (event != MOVED_TO || path?.endsWith("posted_story_snap.2") != true) return + fileObserver.stopWatching() + + val file = File(postedStorySnapFolder, path) + runCatching { + val fileContent = JsonParser.parseReader(file.reader()).asJsonObject + if (fileContent["timerOrDuration"].asLong < 0) file.delete() + }.onFailure { + context.log.error("Failed to read story metadata file", it) + } + } + }) + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + if (event.destinations.stories.isEmpty()) return@subscribe + fileObserver.startWatching() + } + } + + context.mappings.getMappedClass("DefaultMediaItem") + .hookConstructor(HookStage.BEFORE) { param -> + //set the video length argument + param.setArg(5, -1L) + } + } + + //TODO: allow split from any source + if (mode == "split") { + val cameraRollId = context.mappings.getMappedMap("CameraRollMediaId") + // memories grid + findClass(cameraRollId["class"].toString()).hookConstructor(HookStage.AFTER) { param -> + //set the durationMs field + param.thisObject<Any>() + .setObjectField(cameraRollId["durationMsField"].toString(), -1L) + } + + // chat camera roll grid + findClass("com.snap.impala.common.media.MediaLibraryItem").hookConstructor(HookStage.BEFORE) { param -> + //set the video length argument + param.setArg(3, -1L) + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,30 @@ +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")) { + param.canceled = true + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/GooglePlayServicesDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/GooglePlayServicesDialogs.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.app.AlertDialog +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 java.lang.reflect.Modifier + +class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + if (!context.config.global.disableGooglePlayDialogs.get()) return + + findClass("com.google.android.gms.common.GoogleApiAvailability").methods + .first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method -> + method.hook(HookStage.BEFORE) { param -> + context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null") + param.setResult(null) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/LocationSpoofer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/LocationSpoofer.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.core.features.impl.global + +import android.content.Intent +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 + +class LocationSpoofer: Feature("LocationSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + Hooker.hook(context.mainActivity!!.javaClass, "onActivityResult", HookStage.BEFORE) { param -> + val intent = param.argNullable<Intent>(2) ?: return@hook + val bundle = intent.getBundleExtra("location") ?: return@hook + param.setResult(null) + + with(context.config.experimental.spoof.location) { + latitude.set(bundle.getFloat("latitude")) + longitude.set(bundle.getFloat("longitude")) + + context.longToast("Location set to $latitude, $longitude") + } + } + + if (context.config.experimental.spoof.location.globalState != true) return + + val latitude by context.config.experimental.spoof.location.latitude + val longitude by context.config.experimental.spoof.location.longitude + + val locationClass = android.location.Location::class.java + val locationManagerClass = android.location.LocationManager::class.java + + locationClass.hook("getLatitude", HookStage.BEFORE) { it.setResult(latitude.toDouble()) } + locationClass.hook("getLongitude", HookStage.BEFORE) { it.setResult(longitude.toDouble()) } + locationClass.hook("getAccuracy", HookStage.BEFORE) { it.setResult(0.0F) } + + //Might be redundant because it calls isProviderEnabledForUser which we also hook, meaning if isProviderEnabledForUser returns true this will also return true + locationManagerClass.hook("isProviderEnabled", HookStage.BEFORE) { it.setResult(true) } + locationManagerClass.hook("isProviderEnabledForUser", HookStage.BEFORE) { it.setResult(true) } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,23 @@ +package me.rhunk.snapenhance.core.features.impl.global + +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 MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParams = FeatureLoadParams.INIT_SYNC) { + override fun init() { + val enumQualityLevel = context.mappings.getMappedClass("EnumQualityLevel") + val mediaQualityLevelProvider = context.mappings.getMappedMap("MediaQualityLevelProvider") + + val forceMediaSourceQuality by context.config.global.forceMediaSourceQuality + + context.androidContext.classLoader.loadClass(mediaQualityLevelProvider["class"].toString()).hook( + mediaQualityLevelProvider["method"].toString(), + HookStage.BEFORE, + { forceMediaSourceQuality } + ) { param -> + param.setResult(enumQualityLevel.enumConstants.firstOrNull { it.toString() == "LEVEL_MAX" } ) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/SnapchatPlus.kt @@ -0,0 +1,45 @@ +package me.rhunk.snapenhance.core.features.impl.global + +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 + +class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.INIT_SYNC) { + private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) + private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) + + override fun init() { + if (!context.config.global.snapchatPlus.get()) return + + val subscriptionInfoClass = context.mappings.getMappedClass("SubscriptionInfoClass") + + Hooker.hookConstructor(subscriptionInfoClass, HookStage.BEFORE) { param -> + if (param.arg<Int>(0) == 2) return@hookConstructor + //subscription tier + param.setArg(0, 2) + //subscription status + param.setArg(1, 2) + + 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<Any>() + val firstArg = param.arg<Any>(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 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 @@ -0,0 +1,27 @@ +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/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoSave.kt @@ -0,0 +1,138 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.MessageState +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 +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) { + private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() + + 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() + } + + 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 + 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(), + messageId, + context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" }, + callback + ) + }.onFailure { + CoreLogger.xposedLog("Error saving message $messageId", it) + } + + //delay between saves + Thread.sleep(100L) + } + + private fun canSaveMessage(message: Message): Boolean { + 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 { + 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 + } + + override fun asyncOnActivityCreate() { + //called when enter in a conversation (or when a message is sent) + Hooker.hook( + context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"), + "onFetchConversationWithMessagesComplete", + HookStage.BEFORE, + { autoSaveFilter.isNotEmpty() } + ) { param -> + val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!) + if (!canSaveInConversation(conversationId.toString())) return@hook + + val messages = param.arg<List<Any>>(1).map { Message(it) } + messages.forEach { + if (!canSaveMessage(it)) return@forEach + asyncSaveExecutorService.submit { + saveMessage(conversationId, it) + } + } + } + + //called when a message is received + Hooker.hook( + context.mappings.getMappedClass("callbacks", "FetchMessageCallback"), + "onFetchMessageComplete", + HookStage.BEFORE, + { autoSaveFilter.isNotEmpty() } + ) { param -> + val message = Message(param.arg(0)) + val conversationId = message.messageDescriptor.conversationId + if (!canSaveInConversation(conversationId.toString())) return@hook + if (!canSaveMessage(message)) return@hook + + asyncSaveExecutorService.submit { + saveMessage(conversationId, message) + } + } + + Hooker.hook( + context.mappings.getMappedClass("callbacks", "SendMessageCallback"), + "onSuccess", + HookStage.BEFORE, + { autoSaveFilter.isNotEmpty() } + ) { + val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() + val conversationUUID = messaging.openedConversationUUID ?: return@hook + runCatching { + fetchConversationWithMessagesPaginatedMethod.invoke( + messaging.conversationManager, conversationUUID.instanceNonNull(), + Long.MAX_VALUE, + 10, + callback + ) + }.onFailure { + CoreLogger.xposedLog("failed to save message", it) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/DisableReplayInFF.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/DisableReplayInFF.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +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.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<Any>() + 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/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -0,0 +1,94 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +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.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.SnapUUID + +class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { + lateinit var conversationManager: Any + + var openedConversationUUID: SnapUUID? = null + var lastFetchConversationUserUUID: SnapUUID? = null + var lastFetchConversationUUID: SnapUUID? = null + var lastFetchGroupConversationUUID: SnapUUID? = null + var lastFocusedMessageId: Long = -1 + + override fun init() { + Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { + conversationManager = it.thisObject() + } + } + + override fun onActivityCreate() { + context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> + findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> + val viewItemContainer = param.arg<Any>(0) + val viewItem = viewItemContainer.getObjectField(mappings["viewModelField"].toString()).toString() + val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also { + if (it.startsWith("null")) return@hook + } + context.database.getConversationType(conversationId)?.takeIf { it == 1 }?.run { + lastFetchGroupConversationUUID = SnapUUID.fromString(conversationId) + } + } + } + + context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param -> + val userIdToConversation = (param.arg<ArrayList<*>>(0)) + .takeIf { it.isNotEmpty() } + ?.get(0) ?: return@hook + + lastFetchConversationUUID = SnapUUID(userIdToConversation.getObjectField("mConversationId")) + lastFetchConversationUserUUID = SnapUUID(userIdToConversation.getObjectField("mUserId")) + } + + with(context.classCache.conversationManager) { + Hooker.hook(this, "enterConversation", HookStage.BEFORE) { + openedConversationUUID = SnapUUID(it.arg(0)) + } + + Hooker.hook(this, "exitConversation", HookStage.BEFORE) { + openedConversationUUID = null + } + } + + } + + 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()) + }) { + it.setResult(null) + } + } + + //get last opened snap for media downloader + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + openedConversationUUID = event.conversationId + lastFocusedMessageId = event.messageId + } + + Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param -> + lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any)) + lastFocusedMessageId = param.arg(1) + } + + Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE, { + hideTypingNotification || stealthMode.canUseRule(openedConversationUUID.toString()) + }) { + it.setResult(null) + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,370 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.RemoteInput +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Bundle +import android.os.UserHandle +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.download.SplitMediaAssetType +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.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent +import me.rhunk.snapenhance.core.features.Feature +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.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.hook.hook +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 + +class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { + 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<Long, NotificationData>() // messageId => notification + private val cachedMessages = mutableMapOf<String, MutableList<String>>() // conversationId => cached messages + private val notificationIdMap = mutableMapOf<Int, String>() // notificationId => conversationId + + private val notifyAsUserMethod by lazy { + XposedHelpers.findMethodExact( + NotificationManager::class.java, "notifyAsUser", + String::class.java, + Int::class.javaPrimitiveType, + Notification::class.java, + UserHandle::class.java + ) + } + + 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 + } + + private val betterNotificationFilter by lazy { + context.config.messaging.betterNotifications.get() + } + + private fun setNotificationText(notification: Notification, conversationId: String) { + val messageText = StringBuilder().apply { + cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { + if (isNotEmpty()) append("\n") + append(it) + } + }.toString() + + with(notification.extras) { + putString("android.text", messageText) + putString("android.bigText", messageText) + putParcelableArray("android.messages", messageText.split("\n").map { + Bundle().apply { + putBundle("extras", Bundle()) + putString("text", it) + putLong("time", System.currentTimeMillis()) + } + }.toTypedArray()) + } + } + + private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, messageId: Long, notificationData: NotificationData) { + + val notificationBuilder = XposedHelpers.newInstance( + Notification.Builder::class.java, + context.androidContext, + notificationData.notification + ) as Notification.Builder + + val actions = mutableListOf<Notification.Action>() + actions.addAll(notificationData.notification.actions) + + fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { + if (!filter()) return + + val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { + putExtra("conversation_id", conversationId) + putExtra("notification_id", notificationData.id) + putExtra("message_id", messageId) + } + + val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( + context.androidContext, + System.nanoTime().toInt(), + intent, + PendingIntent.FLAG_MUTABLE + )).apply(builder).build() + actions.add(action) + } + + newAction("Reply", ACTION_REPLY, { + betterNotificationFilter.contains("reply_button") && contentType == ContentType.CHAT + }) { + val chatReplyInput = RemoteInput.Builder("chat_reply_input") + .setLabel("Reply") + .build() + it.addRemoteInput(chatReplyInput) + } + + newAction("Download", ACTION_DOWNLOAD, { + betterNotificationFilter.contains("download_button") && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP) + }) {} + + notificationBuilder.setActions(*actions.toTypedArray()) + notificationData.notification = notificationBuilder.build() + } + + private fun setupBroadcastReceiverHook() { + 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 notificationId = intent.getIntExtra("notification_id", -1) + val notificationManager = event.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + 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 + )) + } + } + + when (event.action) { + ACTION_REPLY -> { + val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") + .toString() + + 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") + }) + } + } + ACTION_DOWNLOAD -> { + runCatching { + context.feature(MediaDownloader::class).downloadMessageId(messageId, isPreview = false) + }.onFailure { + context.longToast(it) + } + } + else -> return@subscribe + } + + event.canceled = true + } + } + + private fun fetchMessagesResult(conversationId: String, messages: List<Message>) { + 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 + )) + } + + 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 + } + } + + 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) + + 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()) + ) + + 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) + + runCatching { + val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = { + media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }) ?: throw Throwable("Unable to download media") + + val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>() + + MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream -> + downloadedMedias[type] = inputStream.readBytes() + } + + 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)) + } + + 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 { + CoreLogger.xposedLog("Failed to send preview notification", it) + } + } + } + else -> { + notificationCache.add(formatUsername("sent ${contentType.name.lowercase()}")) + appendNotifications() + } + } + + sendNotificationData(notificationData, false) + }.clear() + } + } + + override fun init() { + setupBroadcastReceiverHook() + + val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") + + Hooker.hook(notifyAsUserMethod, HookStage.BEFORE) { param -> + val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) + + 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.map { it.uppercase() }.none { + notificationType.contains(it) + }) return@hook + + val conversationManager: Any = context.feature(Messaging::class).conversationManager + + synchronized(notificationDataQueue) { + notificationDataQueue[messageId.toLong()] = notificationData + } + + val callback = CallbackBuilder(fetchConversationWithMessagesCallback) + .override("onFetchConversationWithMessagesComplete") { callbackParam -> + val messageList = (callbackParam.arg(1) as List<Any>).map { msg -> Message(msg) } + fetchMessagesResult(conversationId, messageList) + } + .override("onError") { + context.log.error("Failed to fetch message ${it.arg(0) as Any}") + }.build() + + fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instanceNonNull(), callback) + param.setResult(null) + } + + XposedHelpers.findMethodExact( + NotificationManager::class.java, + "cancelAsUser", String::class.java, + Int::class.javaPrimitiveType, + UserHandle::class.java + ).hook(HookStage.BEFORE) { param -> + val notificationId = param.arg<Int>(1) + notificationIdMap[notificationId]?.let { + cachedMessages[it]?.clear() + } + } + + findClass("com.google.firebase.messaging.FirebaseMessagingService").run { + 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<Intent>(0) ?: return@hook + val messageType = intent.getStringExtra("type") ?: return@hook + + context.log.debug("received message type: $messageType") + + if (states.contains(messageType.replaceFirst("mischief_", ""))) { + param.setResult(null) + } + } + } + } + + 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/features/impl/messaging/PreventMessageSending.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventMessageSending.kt @@ -0,0 +1,48 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.NotificationType +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +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 PreventMessageSending : Feature("Prevent message sending", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + override fun asyncOnActivityCreate() { + val preventMessageSending by context.config.messaging.preventMessageSending + + context.event.subscribe(UnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/UpdateContentMessage") return@subscribe + event.buffer = ProtoEditor(event.buffer).apply { + edit(3) { + // replace replayed to read receipt + remove(13) + addBuffer(4, byteArrayOf()) + } + }.toByteArray() + } + + context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> + val messageUpdate = param.arg<Any>(2).toString() + if (messageUpdate == "SCREENSHOT" && preventMessageSending.contains("chat_screenshot")) { + param.setResult(null) + } + + if (messageUpdate == "SCREEN_RECORD" && preventMessageSending.contains("chat_screen_record")) { + param.setResult(null) + } + } + + context.event.subscribe(SendMessageWithContentEvent::class) { event -> + val contentType = event.messageContent.contentType + val associatedType = NotificationType.fromContentType(contentType ?: return@subscribe) ?: return@subscribe + + if (preventMessageSending.contains(associatedType.key)) { + context.log.verbose("Preventing message sending for $associatedType") + event.canceled = true + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventReadReceipts.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/PreventReadReceipts.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +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.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.Hooker +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class PreventReadReceipts : Feature("PreventReadReceipts", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{ + context.feature(StealthMode::class).canUseRule(it.toString()) + } + + arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> + Hooker.hook(context.classCache.conversationManager, methodName, HookStage.BEFORE, { isConversationInStealthMode( + SnapUUID(it.arg(0)) + ) }) { + it.setResult(null) + } + } + + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + if (isConversationInStealthMode(event.conversationId)) { + event.canceled = true + } + } + } +}+ \ 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 @@ -0,0 +1,116 @@ +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 +import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent +import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.messaging.MessageSender +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper + +class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { + private var isLastSnapSavable = false + + override fun init() { + val fixGalleryMediaSendOverride = context.config.experimental.nativeHooks.let { + it.globalState == true && it.fixGalleryMediaOverride.get() + } + val typeNames = mutableListOf( + "ORIGINAL", + "SNAP", + "NOTE" + ).also { + if (fixGalleryMediaSendOverride) { + it.add("SAVABLE_SNAP") + } + }.associateWith { + it + } + + context.event.subscribe(UnaryCallEvent::class, { fixGalleryMediaSendOverride }) { event -> + if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe + if (!isLastSnapSavable) return@subscribe + ProtoReader(event.buffer).also { + // only affect snaps + if (!it.containsPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11)) return@subscribe + } + + event.buffer = ProtoEditor(event.buffer).apply { + //remove the max view time + edit(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11, 5, 2) { + remove(8) + addBuffer(6, byteArrayOf()) + } + //make snaps savable in chat + edit(4) { + val savableState = firstOrNull(7)?.value ?: return@edit + if (savableState == 2L) { + remove(7) + addVarInt(7, 3) + } + } + }.toByteArray() + } + + 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 + + //prevent story replies + val messageProtoReader = ProtoReader(localMessageContent.content) + if (messageProtoReader.contains(7)) return@subscribe + + event.canceled = true + + context.runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) + .setItems(typeNames.values.map { + context.translation["features.options.gallery_media_send_override.$it"] + }.toTypedArray()) { dialog, which -> + dialog.dismiss() + val overrideType = typeNames.keys.toTypedArray()[which] + + if (overrideType != "ORIGINAL" && messageProtoReader.followPath(3)?.getCount(3) != 1) { + context.runOnUiThread { + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) + .setMessage(context.translation["gallery_media_send_override.multiple_media_toast"]) + .setPositiveButton(context.translation["button.ok"], null) + .show() + } + return@setItems + } + + when (overrideType) { + "SNAP", "SAVABLE_SNAP" -> { + val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer() + + localMessageContent.contentType = ContentType.SNAP + localMessageContent.content = MessageSender.redSnapProto(extras) + if (overrideType == "SAVABLE_SNAP") { + isLastSnapSavable = true + } + } + + "NOTE" -> { + localMessageContent.contentType = ContentType.NOTE + val mediaDuration = + messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: 0 + localMessageContent.content = + MessageSender.audioNoteProto(mediaDuration) + } + } + + event.invokeOriginal() + } + .setNegativeButton(context.translation["button.cancel"], null) + .show() + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MessageState +import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams + +class UnlimitedSnapViewTime : + Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + override fun onActivityCreate() { + val state by context.config.messaging.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 + + val messageContent = event.message.messageContent + + val mediaAttributes = ProtoReader(messageContent.content).followPath(11, 5, 2) ?: return@subscribe + if (mediaAttributes.contains(6)) return@subscribe + messageContent.content = ProtoEditor(messageContent.content).apply { + edit(11, 5, 2) { + remove(8) + addBuffer(6, byteArrayOf()) + } + }.toByteArray() + } + } +} 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 @@ -0,0 +1,179 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.Shape +import android.os.DeadObjectException +import com.google.gson.JsonObject +import com.google.gson.JsonParser +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.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.ui.addForegroundDrawable +import me.rhunk.snapenhance.core.ui.removeForegroundDrawable +import me.rhunk.snapenhance.core.util.EvictingMap +import java.util.concurrent.Executors +import kotlin.system.measureTimeMillis + +private fun Any.longHashCode(): Long { + var h = 1125899906842597L + val value = this.toString() + for (element in value) h = 31 * h + element.code.toLong() + return h +} + +class MessageLogger : Feature("MessageLogger", + loadParams = FeatureLoadParams.INIT_SYNC or + FeatureLoadParams.ACTIVITY_CREATE_SYNC or + FeatureLoadParams.ACTIVITY_CREATE_ASYNC +) { + companion object { + const val PREFETCH_MESSAGE_COUNT = 20 + const val PREFETCH_FEED_COUNT = 20 + const val DELETED_MESSAGE_COLOR = 0x2Eb71c1c + } + + private val messageLoggerInterface by lazy { context.bridgeClient.getMessageLogger() } + + val isEnabled get() = context.config.messaging.messageLogger.get() + + private val threadPool = Executors.newFixedThreadPool(10) + + private val cachedIdLinks = mutableMapOf<Long, Long>() // client id -> server id + private val fetchedMessages = mutableListOf<Long>() // list of unique message ids + private val deletedMessageCache = EvictingMap<Long, JsonObject>(200) // unique message id -> message json object + + fun isMessageDeleted(conversationId: String, clientMessageId: Long) + = makeUniqueIdentifier(conversationId, clientMessageId)?.let { deletedMessageCache.containsKey(it) } ?: false + + fun deleteMessage(conversationId: String, clientMessageId: Long) { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return + fetchedMessages.remove(uniqueMessageId) + deletedMessageCache.remove(uniqueMessageId) + messageLoggerInterface.deleteMessage(conversationId, uniqueMessageId) + } + + fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? { + val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null + if (deletedMessageCache.containsKey(uniqueMessageId)) { + return deletedMessageCache[uniqueMessageId] + } + return messageLoggerInterface.getMessage(conversationId, uniqueMessageId)?.let { + JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject + } + } + + fun getMessageProto(conversationId: String, clientMessageId: Long): ProtoReader? { + return getMessageObject(conversationId, clientMessageId)?.let { message -> + ProtoReader(message.getAsJsonObject("mMessageContent").getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray()) + } + } + + private fun computeMessageIdentifier(conversationId: String, orderKey: Long) = (orderKey.toString() + conversationId).longHashCode() + + private fun makeUniqueIdentifier(conversationId: String, clientMessageId: Long): Long? { + val serverMessageId = cachedIdLinks[clientMessageId] ?: + context.database.getConversationMessageFromId(clientMessageId)?.serverMessageId?.toLong()?.also { + cachedIdLinks[clientMessageId] = it + } + ?: return run { + context.log.error("Failed to get server message id for $conversationId $clientMessageId") + null + } + return computeMessageIdentifier(conversationId, serverMessageId) + } + + override fun asyncOnActivityCreate() { + if (!isEnabled || !context.database.hasArroyo()) { + return + } + + measureTimeMillis { + val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! } + if (conversationIds.isEmpty()) return@measureTimeMillis + fetchedMessages.addAll(messageLoggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList()) + }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") } + } + + override fun init() { + context.event.subscribe(BuildMessageEvent::class, { isEnabled }, priority = 1) { event -> + 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() + //exclude messages sent by me + if (event.message.senderId.toString() == context.database.myUserId) return@subscribe + + val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, event.message.orderKey) + + if (event.message.messageContent.contentType != ContentType.STATUS) { + if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe + fetchedMessages.add(uniqueMessageIdentifier) + + threadPool.execute { + try { + messageLoggerInterface.addMessage(conversationId, uniqueMessageIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) + } catch (ignored: DeadObjectException) {} + } + + return@subscribe + } + + //query the deleted message + val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier)) + deletedMessageCache[uniqueMessageIdentifier] + else { + messageLoggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let { + JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject + } + } ?: return@subscribe + + val messageJsonObject = deletedMessageObject.asJsonObject + + //if the message is a snap make it playable + if (messageJsonObject["mMessageContent"]?.asJsonObject?.get("mContentType")?.asString == "SNAP") { + messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") + } + + //serialize all properties of messageJsonObject and put in the message object + messageInstance.javaClass.declaredFields.forEach { field -> + field.isAccessible = true + if (field.name == "mDescriptor") return@forEach // prevent the client message id from being overwritten + messageJsonObject[field.name]?.let { fieldValue -> + field.set(messageInstance, context.gson.fromJson(fieldValue, field.type)) + } + } + + deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject + } + } + + override fun onActivityCreate() { + if (!isEnabled) return + + context.event.subscribe(BindViewEvent::class) { event -> + event.chatMessage { conversationId, messageId -> + event.view.removeForegroundDrawable("deletedMessage") + makeUniqueIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> + if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage + } ?: return@chatMessage + + event.view.addForegroundDrawable("deletedMessage", ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + canvas.drawRect(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), Paint().apply { + color = DELETED_MESSAGE_COLOR + }) + } + })) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/SnapToChatMedia.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/SnapToChatMedia.kt @@ -0,0 +1,25 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +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.messaging.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/spying/StealthMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.core.features.MessagingRuleFeature + +class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH)+ \ No newline at end of file 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 @@ -0,0 +1,75 @@ +package me.rhunk.snapenhance.core.features.impl.tweaks + +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.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.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.ScSize + +class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + + private fun parseResolution(resolution: String): IntArray { + return resolution.split("x").map { it.toInt() }.toIntArray() + } + + @SuppressLint("MissingPermission", "DiscouragedApi") + override fun onActivityCreate() { + if (context.config.camera.disable.get()) { + ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> + val permission = param.arg<String>(0) + if (permission == Manifest.permission.CAMERA) { + param.setResult(PackageManager.PERMISSION_GRANTED) + } + } + + CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param -> + param.setResult(null) + } + } + + 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<Key<*>>(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)) + + if (previewResolution.isPresent() && captureResolution.isPresent()) { + previewResolutionConfig?.let { + previewResolution.first = it[0] + previewResolution.second = it[1] + } + + captureResolutionConfig?.let { + captureResolution.first = it[0] + captureResolution.second = it[1] + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ClientBootstrapOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ClientBootstrapOverride.kt @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.config.impl.UserInterfaceTweaks +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import java.io.File + + +class ClientBootstrapOverride : Feature("ClientBootstrapOverride", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + + 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, (UserInterfaceTweaks.BootstrapOverride.tabs.indexOf(currentTab) + 1).toByte())) + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,106 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.ShapeDrawable +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 +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 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)) + ).getColor(0, 0) + } + + 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 + + val ffItemId = context.resources.getIdentifier("ff_item", "id", Constants.SNAPCHAT_PACKAGE_NAME) + + 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 feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + ffSdlAvatarStartMargin + val separatorHeight = (context.resources.displayMetrics.density * 2).toInt() + val textPaint = TextPaint().apply { + textSize = secondaryTextSize + } + + context.event.subscribe(BindViewEvent::class) { param -> + param.friendFeedItem { conversationId -> + val frameLayout = param.view as ViewGroup + val ffItem = frameLayout.findViewById<View>(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) + ?: return@mapNotNull null + + val messageString = messageContainer.getString(2, 1) + ?: ContentType.fromMessageContainer(messageContainer)?.name + ?: return@mapNotNull null + + val friendName = context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" + + "$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 + } + + 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 + ) + } + } + })) + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,19 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +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<Any>().getObjectField("mStreakMetadata") ?: return@hookConstructor + streakMetadata.setObjectField("mExpiredStreak", null) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/OldBitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/OldBitmojiSelfie.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.features.FeatureLoadParams + +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/core/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/PinConversations.kt @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.data.RuleState +import me.rhunk.snapenhance.core.features.FeatureLoadParams +import me.rhunk.snapenhance.core.features.MessagingRuleFeature +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.getObjectField +import me.rhunk.snapenhance.core.util.ktx.setObjectField +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +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<Any>(1).toString() == "PINNED" + setState(conversationUUID.toString(), isPinned) + } + + context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", 1L) + } + } + + context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject<Any>() + val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) + val isPinned = getState(conversationUUID.toString()) + if (isPinned) { + instance.setObjectField("mPinnedTimestampMs", 1L) + } + } + } + + override fun getRuleState() = RuleState.WHITELIST +}+ \ No newline at end of file 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 @@ -0,0 +1,150 @@ +package me.rhunk.snapenhance.core.features.impl.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.text.SpannableString +import android.view.View +import android.view.ViewGroup.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 + +class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val identifierCache = mutableMapOf<String, Int>() + + @SuppressLint("DiscouragedApi") + fun getIdentifier(name: String, defType: String): Int { + return identifierCache.getOrPut("$name:$defType") { + context.resources.getIdentifier(name, defType, Constants.SNAPCHAT_PACKAGE_NAME) + } + } + + private fun hideStorySection(event: AddViewEvent) { + val parent = event.parent + parent.visibility = View.GONE + val marginLayoutParams = parent.layoutParams as MarginLayoutParams + marginLayoutParams.setMargins(-99999, -99999, -99999, -99999) + event.canceled = true + } + + private var surfaceViewAspectRatio: Float = 0f + + @SuppressLint("DiscouragedApi", "InternalInsetResource") + override fun onActivityCreate() { + val blockAds by context.config.global.blockAds + val hiddenElements by context.config.userInterface.hideUiComponents + val hideStorySections by context.config.userInterface.hideStorySections + val isImmersiveCamera by context.config.camera.immersiveCameraPreview + + 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 chatNoteRecordButton = getIdentifier("chat_note_record_button", "id") + + View::class.java.hook("setVisibility", HookStage.BEFORE) { methodParam -> + val viewId = (methodParam.thisObject() as View).id + if (viewId == callButton1 || viewId == callButton2) { + if (!hiddenElements.contains("hide_profile_call_buttons")) return@hook + methodParam.setArg(0, View.GONE) + } + } + + Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( + HookStage.AFTER, + { isImmersiveCamera } + ) { param -> + val id = param.arg<Int>(0) + if (id == getIdentifier("capri_viewfinder_default_corner_radius", "dimen") || + id == getIdentifier("ngs_hova_nav_larger_camera_button_size", "dimen")) { + param.setResult(0) + } + } + + context.event.subscribe(AddViewEvent::class) { event -> + val viewId = event.view.id + val view = event.view + + if (hideStorySections.contains("hide_for_you")) { + if (viewId == getIdentifier("df_large_story", "id") || + viewId == getIdentifier("df_promoted_story", "id")) { + hideStorySection(event) + return@subscribe + } + if (viewId == getIdentifier("stories_load_progress_layout", "id")) { + event.canceled = true + } + } + + if (hideStorySections.contains("hide_friends") && viewId == getIdentifier("friend_card_frame", "id")) { + hideStorySection(event) + } + + //mappings? + if (hideStorySections.contains("hide_friend_suggestions") && view.javaClass.superclass?.name?.endsWith("StackDrawLayout") == true) { + val layoutParams = view.layoutParams as? FrameLayout.LayoutParams ?: return@subscribe + if (layoutParams.width == -1 && + layoutParams.height == -2 && + view.javaClass.let { clazz -> + clazz.methods.any { it.returnType == SpannableString::class.java} && + clazz.constructors.any { it.parameterCount == 1 && it.parameterTypes[0] == Context::class.java } + } + ) { + hideStorySection(event) + } + } + + if (hideStorySections.contains("hide_suggested") && (viewId == getIdentifier("df_small_story", "id")) + ) { + hideStorySection(event) + } + + if (blockAds && viewId == getIdentifier("df_promoted_story", "id")) { + hideStorySection(event) + } + + if (isImmersiveCamera) { + if (view.id == getIdentifier("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")) { + Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { + it.setArg(1, 1) + it.setArg(3, displayMetrics.heightPixels) + } + } + } + + 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 == callButtonsStub && hiddenElements.contains("hide_chat_call_buttons")) + ) { + view.apply { + view.post { + isEnabled = false + setWillNotDraw(true) + view.visibility = View.GONE + } + addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + view.post { view.visibility = View.GONE } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/AbstractLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/AbstractLogger.kt @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.core.logger - -abstract class AbstractLogger( - logChannel: LogChannel, -) { - private val TAG = logChannel.shortName - - - open fun debug(message: Any?, tag: String = TAG) {} - - open fun error(message: Any?, tag: String = TAG) {} - - open fun error(message: Any?, throwable: Throwable, tag: String = TAG) {} - - open fun info(message: Any?, tag: String = TAG) {} - - open fun verbose(message: Any?, tag: String = TAG) {} - - open fun warn(message: Any?, tag: String = TAG) {} - - open fun assert(message: Any?, tag: String = TAG) {} -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/CoreLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/CoreLogger.kt @@ -0,0 +1,77 @@ +package me.rhunk.snapenhance.core.logger + +import android.annotation.SuppressLint +import android.util.Log +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.logger.LogChannel +import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.core.bridge.BridgeClient +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + + +@SuppressLint("PrivateApi") +class CoreLogger( + private val bridgeClient: BridgeClient +): AbstractLogger(LogChannel.CORE) { + companion object { + private const val TAG = "SnapEnhanceCore" + + fun xposedLog(message: Any?, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + } + + fun xposedLog(message: Any?, throwable: Throwable, tag: String = TAG) { + Log.println(Log.INFO, tag, message.toString()) + XposedBridge.log("$tag: $message") + XposedBridge.log(throwable) + } + } + + 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 { + invokeOriginalPrintLog(logLevel.priority, tag, message.toString()) + } + } + + override fun debug(message: Any?, tag: String) = internalLog(tag, LogLevel.DEBUG, message) + + override fun error(message: Any?, tag: String) = internalLog(tag, LogLevel.ERROR, message) + + override fun error(message: Any?, throwable: Throwable, tag: String) { + internalLog(tag, LogLevel.ERROR, message) + internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString()) + } + + override fun info(message: Any?, tag: String) = internalLog(tag, LogLevel.INFO, message) + + override fun verbose(message: Any?, tag: String) = internalLog(tag, LogLevel.VERBOSE, message) + + override fun warn(message: Any?, tag: String) = internalLog(tag, LogLevel.WARN, message) + + override fun assert(message: Any?, tag: String) = internalLog(tag, LogLevel.ASSERT, message) +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/LogChannel.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/LogChannel.kt @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.core.logger - -enum class LogChannel( - 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): LogChannel? { - return entries.find { it.channel == channel } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/LogLevel.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/logger/LogLevel.kt @@ -1,30 +0,0 @@ -package me.rhunk.snapenhance.core.logger - -import android.util.Log - -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); - - companion object { - fun fromLetter(letter: String): LogLevel? { - return entries.find { it.letter == letter } - } - - fun fromShortName(shortName: String): LogLevel? { - return entries.find { it.shortName == shortName } - } - - fun fromPriority(priority: Int): LogLevel? { - return entries.find { it.priority == priority } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/Manager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/Manager.kt @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.core.manager + +interface Manager { + fun init() {} + fun onActivityCreate() {} +}+ \ No newline at end of file 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 @@ -0,0 +1,43 @@ +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.CleanCache +import me.rhunk.snapenhance.core.action.impl.ExportChatMessages +import me.rhunk.snapenhance.core.action.impl.OpenMap +import me.rhunk.snapenhance.core.manager.Manager + +class ActionManager( + private val modContext: ModContext, +) : Manager { + + private val actions by lazy { + mapOf( + EnumAction.CLEAN_CACHE to CleanCache::class, + EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class, + EnumAction.OPEN_MAP to OpenMap::class, + ).map { + it.key to it.value.java.getConstructor().newInstance().apply { + this.context = modContext + } + }.toMap().toMutableMap() + } + + override fun init() { + } + + fun onNewIntent(intent: Intent?) { + val action = intent?.getStringExtra(EnumAction.ACTION_PARAMETER) ?: return + execute(EnumAction.entries.find { it.key == action } ?: return) + intent.removeExtra(EnumAction.ACTION_PARAMETER) + } + + fun execute(enumAction: EnumAction) { + val action = actions[enumAction] ?: return + action.run() + if (enumAction.exitOnFinish) { + modContext.forceCloseApp() + } + } +}+ \ 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 @@ -0,0 +1,164 @@ +package me.rhunk.snapenhance.core.manager.impl + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.Feature +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.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.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.* +import me.rhunk.snapenhance.core.logger.CoreLogger +import me.rhunk.snapenhance.core.manager.Manager +import me.rhunk.snapenhance.core.ui.menu.impl.MenuViewInjector +import kotlin.reflect.KClass +import kotlin.system.measureTimeMillis + +class FeatureManager( + private val context: ModContext +) : Manager { + private val features = mutableListOf<Feature>() + + private fun register(vararg featureClasses: KClass<out Feature>) { + runBlocking { + featureClasses.forEach { clazz -> + launch(Dispatchers.IO) { + runCatching { + clazz.java.constructors.first().newInstance() + .let { it as Feature } + .also { + it.context = context + synchronized(features) { + features.add(it) + } + } + }.onFailure { + CoreLogger.xposedLog("Failed to register feature ${clazz.simpleName}", it) + } + } + } + } + } + + @Suppress("UNCHECKED_CAST") + fun <T : Feature> get(featureClass: KClass<T>): T? { + return features.find { it::class == featureClass } as? T + } + + fun getRuleFeatures() = features.filterIsInstance<MessagingRuleFeature>() + + override fun init() { + register( + EndToEndEncryption::class, + ScopeSync::class, + Messaging::class, + MediaDownloader::class, + StealthMode::class, + MenuViewInjector::class, + PreventReadReceipts::class, + AnonymousStoryViewing::class, + MessageLogger::class, + SnapchatPlus::class, + DisableMetrics::class, + PreventMessageSending::class, + Notifications::class, + AutoSave::class, + UITweaks::class, + ConfigurationOverride::class, + SendOverride::class, + UnlimitedSnapViewTime::class, + BypassVideoLengthRestriction::class, + MediaQualityLevelOverride::class, + MeoPasscodeBypass::class, + AppPasscode::class, + LocationSpoofer::class, + CameraTweaks::class, + InfiniteStoryBoost::class, + AmoledDarkMode::class, + PinConversations::class, + UnlimitedMultiSnap::class, + DeviceSpooferHook::class, + ClientBootstrapOverride::class, + GooglePlayServicesDialogs::class, + NoFriendScoreDelay::class, + ProfilePictureDownloader::class, + AddFriendSourceSpoof::class, + DisableReplayInFF::class, + OldBitmojiSelfie::class, + SnapToChatMedia::class, + FriendFeedMessagePreview::class, + HideStreakRestore::class, + ) + + initializeFeatures() + } + + private fun initFeatures( + syncParam: Int, + asyncParam: Int, + syncAction: (Feature) -> Unit, + asyncAction: (Feature) -> Unit + ) { + fun tryInit(feature: Feature, block: () -> Unit) { + runCatching { + block() + }.onFailure { + context.log.error("Failed to init feature ${feature.featureKey}", it) + context.longToast("Failed to init feature ${feature.featureKey}! Check logcat for more details.") + } + } + + features.toList().forEach { feature -> + if (feature.loadParams and syncParam != 0) { + tryInit(feature) { + syncAction(feature) + } + } + if (feature.loadParams and asyncParam != 0) { + context.coroutineScope.launch { + tryInit(feature) { + asyncAction(feature) + } + } + } + } + } + + private fun initializeFeatures() { + //TODO: async called when all features are initiated ? + measureTimeMillis { + initFeatures( + FeatureLoadParams.INIT_SYNC, + FeatureLoadParams.INIT_ASYNC, + Feature::init, + Feature::asyncInit + ) + }.also { + context.log.verbose("feature manager init took $it ms") + } + } + + override fun onActivityCreate() { + measureTimeMillis { + initFeatures( + FeatureLoadParams.ACTIVITY_CREATE_SYNC, + FeatureLoadParams.ACTIVITY_CREATE_ASYNC, + Feature::onActivityCreate, + Feature::asyncOnActivityCreate + ) + }.also { + context.log.verbose("feature manager onActivityCreate took $it ms") + } + } +}+ \ 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 @@ -0,0 +1,325 @@ +package me.rhunk.snapenhance.core.messaging + +import android.os.Environment +import android.util.Base64InputStream +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.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<ContentType>? = null, + private val printLog: (String) -> Unit = {}, +) { + private lateinit var conversationParticipants: Map<String, FriendInfo> + private lateinit var messages: List<Message> + + fun readMessages(messages: List<Message>) { + 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() + } + + suspend fun exportHtml(output: OutputStream) { + val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } + val mediaFiles = Collections.synchronizedMap(mutableMapOf<String, Pair<FileType, File>>()) + 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) + }) { + it.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(""" + <!DOCTYPE html> + <html> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title></title> + </head> + """.trimIndent().toByteArray()) + + output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray()) + + mediaFiles.forEach { (key, filePair) -> + output.write("<div class=\"media-$key\"><!-- ".toByteArray()) + + val deflateInputStream = DeflaterInputStream(filePair.second.inputStream(), Deflater(Deflater.BEST_COMPRESSION, true)) + val base64InputStream = XposedHelpers.newInstance( + Base64InputStream::class.java, + deflateInputStream, + android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, + true + ) as InputStream + base64InputStream.copyTo(output) + deflateInputStream.close() + + output.write(" --></div>\n".toByteArray()) + output.flush() + updateProgress("wrote") + } + printLog("writing json conversation data...") + + //write the json file + output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray()) + exportJson(output) + output.write("</script>\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("<script>".toByteArray()) + apkFile.getInputStream(entry).copyTo(output) + output.write("</script>\n".toByteArray()) + } + + //export avenir next font + apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> + val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + output.write(""" + <style> + @font-face { + font-family: 'Avenir Next'; + src: url('data:font/truetype;charset=utf-8;base64, $encodedFontData'); + font-weight: normal; + font-style: normal; + } + </style> + """.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("</html>".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<String, Int>() + + 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<SnapUUID>) { + 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) { + val output = FileOutputStream(outputFile) + + when (exportFormat) { + ExportFormat.HTML -> exportHtml(output) + ExportFormat.JSON -> exportJson(output) + ExportFormat.TEXT -> exportText(output) + } + + output.close() + } +}+ \ No newline at end of file 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 @@ -0,0 +1,181 @@ +package me.rhunk.snapenhance.core.messaging + +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.MetricsMessageMediaType +import me.rhunk.snapenhance.common.data.MetricsMessageType +import me.rhunk.snapenhance.common.util.protobuf.ProtoWriter +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import me.rhunk.snapenhance.core.wrapper.impl.MessageDestinations +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID + +class MessageSender( + private val context: ModContext, +) { + companion object { + val redSnapProto: (ByteArray?) -> ByteArray = { extras -> + ProtoWriter().apply { + from(11) { + from(5) { + from(1) { + 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()) + } + } + extras?.let { + addBuffer(13, it) + } + } + }.toByteArray() + } + + val audioNoteProto: (Long) -> ByteArray = { duration -> + ProtoWriter().apply { + from(6, 1) { + from(1) { + addVarInt(2, 4) + from(5) { + addVarInt(1, 0) + addVarInt(2, 0) + } + addVarInt(7, 0) + addVarInt(13, duration) + } + } + }.toByteArray() + } + + } + + private val sendMessageCallback by lazy { context.mappings.getMappedClass("callbacks", "SendMessageCallback") } + + private val platformAnalyticsCreatorClass by lazy { + context.mappings.getMappedClass("PlatformAnalyticsCreator") + } + + private fun defaultPlatformAnalytics(): ByteArray { + val analyticsSource = platformAnalyticsCreatorClass.constructors[0].parameterTypes[0] + val chatAnalyticsSource = analyticsSource.enumConstants.first { it.toString() == "CHAT" } + + val platformAnalyticsDefaultArgs = arrayOf(chatAnalyticsSource, null, null, null, null, null, null, null, null, null, 0L, 0L, + null, null, false, null, null, 0L, null, null, false, null, null, + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, false, null, null, false, 0L, -2, 8191) + + val platformAnalyticsInstance = platformAnalyticsCreatorClass.constructors[0].newInstance( + *platformAnalyticsDefaultArgs + ) ?: throw Exception("Failed to create platform analytics instance") + + return platformAnalyticsInstance.javaClass.declaredMethods.first { it.returnType == ByteArray::class.java } + .invoke(platformAnalyticsInstance) as ByteArray? + ?: throw Exception("Failed to get platform analytics content") + } + + private fun createLocalMessageContentTemplate( + contentType: ContentType, + messageContent: ByteArray, + localMediaReference: ByteArray? = null, + metricMessageMediaType: MetricsMessageMediaType = MetricsMessageMediaType.DERIVED_FROM_MESSAGE_TYPE, + metricsMediaType: MetricsMessageType = MetricsMessageType.TEXT, + savePolicy: String = "PROHIBITED", + ): String { + return """ + { + "mAllowsTranscription": false, + "mBotMention": false, + "mContent": [${messageContent.joinToString(",")}], + "mContentType": "${contentType.name}", + "mIncidentalAttachments": [], + "mLocalMediaReferences": [${ + if (localMediaReference != null) { + "{\"mId\": [${localMediaReference.joinToString(",")}]}" + } else { + "" + } + }], + "mPlatformAnalytics": { + "mAttemptId": { + "mId": [${(1..16).map { (-127 ..127).random() }.joinToString(",")}] + }, + "mContent": [${defaultPlatformAnalytics().joinToString(",")}], + "mMetricsMessageMediaType": "${metricMessageMediaType.name}", + "mMetricsMessageType": "${metricsMediaType.name}", + "mReactionSource": "NONE" + }, + "mSavePolicy": "$savePolicy" + } + """.trimIndent() + } + + private fun internalSendMessage(conversations: List<SnapUUID>, localMessageContentTemplate: String, callback: Any) { + val sendMessageWithContentMethod = context.classCache.conversationManager.declaredMethods.first { it.name == "sendMessageWithContent" } + + 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() + } + + sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager, messageDestinations.instanceNonNull(), localMessageContent, callback) + } + + //TODO: implement sendSnapMessage + /* + fun sendSnapMessage(conversations: List<SnapUUID>, chatMediaType: ChatMediaType, uri: Uri, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + val mediaReferenceBuffer = FlatBufferBuilder(0).apply { + val uriOffset = createString(uri.toString()) + forceDefaults(true) + startTable(2) + addOffset(1, uriOffset, 0) + addInt(0, chatMediaType.value, 0) + finish(endTable()) + finished() + }.sizedByteArray() + + internalSendMessage(conversations, createLocalMessageContentTemplate( + contentType = ContentType.SNAP, + messageContent = redSnapProto(chatMediaType == ChatMediaType.AUDIO || chatMediaType == ChatMediaType.VIDEO), + localMediaReference = mediaReferenceBuffer, + metricMessageMediaType = MetricsMessageMediaType.IMAGE, + metricsMediaType = MetricsMessageType.SNAP + ), CallbackBuilder(sendMessageCallback) + .override("onSuccess") { + onSuccess() + } + .override("onError") { + onError(it.arg(0)) + } + .build()) + }*/ + + fun sendChatMessage(conversations: List<SnapUUID>, message: String, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + internalSendMessage(conversations, createLocalMessageContentTemplate(ContentType.CHAT, ProtoWriter().apply { + from(2) { + addString(1, message) + } + }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) + .override("onSuccess", callback = { onSuccess() }) + .override("onError", callback = { onError(it.arg(0)) }) + .build()) + } + + fun sendCustomChatMessage(conversations: List<SnapUUID>, contentType: ContentType, message: ProtoWriter.() -> Unit, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { + internalSendMessage(conversations, createLocalMessageContentTemplate(contentType, ProtoWriter().apply { + message() + }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) + .override("onSuccess", callback = { onSuccess() }) + .override("onError", callback = { onError(it.arg(0)) }) + .build()) + } +}+ \ 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 @@ -1,73 +0,0 @@ -package me.rhunk.snapenhance.core.messaging - -import me.rhunk.snapenhance.core.util.SerializableDataObject - - -enum class RuleState( - val key: String -) { - BLACKLIST("blacklist"), - WHITELIST("whitelist"); - - companion object { - fun getByName(name: String) = entries.first { it.key == name } - } -} - -enum class SocialScope( - val key: String, - val tabRoute: String, -) { - FRIEND("friend", "friend_info/{id}"), - GROUP("group", "group_info/{id}"); - - companion object { - fun getByName(name: String) = entries.first { it.key == name } - } -} - -enum class MessagingRuleType( - val key: String, - val listMode: Boolean, - val showInFriendMenu: Boolean = true -) { - AUTO_DOWNLOAD("auto_download", true), - STEALTH("stealth", true), - AUTO_SAVE("auto_save", true), - HIDE_CHAT_FEED("hide_chat_feed", false, showInFriendMenu = false), - E2E_ENCRYPTION("e2e_encryption", false), - PIN_CONVERSATION("pin_conversation", false, showInFriendMenu = false); - - fun translateOptionKey(optionKey: String): String { - return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name" - } - - companion object { - fun getByName(name: String) = entries.firstOrNull { it.key == name } - } -} - -data class FriendStreaks( - val userId: String, - val notify: Boolean, - val expirationTimestamp: Long, - val length: Int -) : SerializableDataObject() { - fun hoursLeft() = (expirationTimestamp - System.currentTimeMillis()) / 1000 / 60 / 60 - - fun isAboutToExpire(expireHours: Int) = expirationTimestamp - System.currentTimeMillis() < expireHours * 60 * 60 * 1000 -} - -data class MessagingGroupInfo( - val conversationId: String, - val name: String, - val participantsCount: Int -) : SerializableDataObject() - -data class MessagingFriendInfo( - val userId: String, - val displayName: String?, - val mutableUsername: String, - val bitmojiId: String?, - val selfieId: String? -) : SerializableDataObject() 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 @@ -0,0 +1,56 @@ +package me.rhunk.snapenhance.core.scripting + +import android.content.Context +import me.rhunk.snapenhance.bridge.scripting.IPCListener +import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.IPCInterface +import me.rhunk.snapenhance.common.scripting.Listener +import me.rhunk.snapenhance.common.scripting.ScriptRuntime +import me.rhunk.snapenhance.core.scripting.impl.ScriptHooker + +class CoreScriptRuntime( + androidContext: Context, + logger: AbstractLogger, +): ScriptRuntime(androidContext, logger) { + private val scriptHookers = mutableListOf<ScriptHooker>() + + fun connect(scriptingInterface: IScripting) { + scriptingInterface.apply { + buildModuleObject = { module -> + putConst("ipc", this, object: IPCInterface() { + override fun onBroadcast(channel: String, eventName: String, listener: Listener) { + registerIPCListener(channel, eventName, object: IPCListener.Stub() { + override fun onMessage(args: Array<out String?>) { + listener(args) + } + }) + } + + override fun on(eventName: String, listener: Listener) { + onBroadcast(module.moduleInfo.name, eventName, listener) + } + + override fun emit(eventName: String, vararg args: String?) { + broadcast(module.moduleInfo.name, eventName, *args) + } + + override fun broadcast(channel: String, eventName: String, vararg args: String?) { + sendIPCMessage(channel, eventName, args) + } + }) + putConst("hooker", this, ScriptHooker(module.moduleInfo, logger, androidContext.classLoader).also { + scriptHookers.add(it) + }) + } + } + + scriptingInterface.enabledScripts.forEach { path -> + runCatching { + load(path, scriptingInterface.getScriptContent(path)) + }.onFailure { + logger.error("Failed to load script $path", it) + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/ScriptHooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/scripting/impl/ScriptHooker.kt @@ -0,0 +1,161 @@ +package me.rhunk.snapenhance.core.scripting.impl + +import me.rhunk.snapenhance.common.logger.AbstractLogger +import me.rhunk.snapenhance.common.scripting.toPrimitiveValue +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +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 me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.hook.hookConstructor +import org.mozilla.javascript.annotations.JSGetter +import org.mozilla.javascript.annotations.JSSetter +import java.lang.reflect.Constructor +import java.lang.reflect.Member +import java.lang.reflect.Method + + +class ScriptHookCallback( + private val hookAdapter: HookAdapter +) { + var result + @JSGetter("result") get() = hookAdapter.getResult() + @JSSetter("result") set(result) = hookAdapter.setResult(result.toPrimitiveValue(lazy { + when (val member = hookAdapter.method()) { + is Method -> member.returnType.name + else -> "void" + } + })) + + val thisObject + @JSGetter("thisObject") get() = hookAdapter.nullableThisObject<Any>() + + val method + @JSGetter("method") get() = hookAdapter.method() + + val args + @JSGetter("args") get() = hookAdapter.args().toList() + + private val parameterTypes by lazy { + when (val member = hookAdapter.method()) { + is Method -> member.parameterTypes + is Constructor<*> -> member.parameterTypes + else -> emptyArray() + }.toList() + } + + fun cancel() = hookAdapter.setResult(null) + + fun arg(index: Int) = hookAdapter.argNullable<Any>(index) + + fun setArg(index: Int, value: Any) { + hookAdapter.setArg(index, value.toPrimitiveValue(lazy { parameterTypes[index].name })) + } + + fun invokeOriginal() = hookAdapter.invokeOriginal() + + fun invokeOriginal(args: Array<Any>) = hookAdapter.invokeOriginal(args.map { + it.toPrimitiveValue(lazy { parameterTypes[args.indexOf(it)].name }) ?: it + }.toTypedArray()) + + override fun toString(): String { + return "ScriptHookCallback(\n" + + " thisObject=${ runCatching { thisObject.toString() }.getOrNull() },\n" + + " args=${ runCatching { args.toString() }.getOrNull() }\n" + + " result=${ runCatching { result.toString() }.getOrNull() },\n" + + ")" + } +} + + +typealias HookCallback = (ScriptHookCallback) -> Unit +typealias HookUnhook = () -> Unit + +@Suppress("unused", "MemberVisibilityCanBePrivate") +class ScriptHooker( + private val moduleInfo: ModuleInfo, + private val logger: AbstractLogger, + private val classLoader: ClassLoader +) { + private val hooks = mutableListOf<HookUnhook>() + + // -- search for class members + + private fun findClassSafe(className: String): Class<*>? { + return runCatching { + classLoader.loadClass(className) + }.onFailure { + logger.warn("Failed to load class $className") + }.getOrNull() + } + + private fun getHookStageFromString(stage: String): HookStage { + return when (stage) { + "before" -> HookStage.BEFORE + "after" -> HookStage.AFTER + else -> throw IllegalArgumentException("Invalid stage: $stage") + } + } + + fun findMethod(clazz: Class<*>, methodName: String): Member? { + return clazz.declaredMethods.find { it.name == methodName } + } + + fun findMethodWithParameters(clazz: Class<*>, methodName: String, vararg types: String): Member? { + return clazz.declaredMethods.find { method -> method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findMethod(className: String, methodName: String): Member? { + return findClassSafe(className)?.let { findMethod(it, methodName) } + } + + fun findMethodWithParameters(className: String, methodName: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findMethodWithParameters(it, methodName, *types) } + } + + fun findConstructor(clazz: Class<*>, vararg types: String): Member? { + return clazz.declaredConstructors.find { constructor -> constructor.parameterTypes.map { it.name }.toTypedArray() contentEquals types } + } + + fun findConstructorParameters(className: String, vararg types: String): Member? { + return findClassSafe(className)?.let { findConstructor(it, *types) } + } + + // -- hooking + + fun hook(method: Member, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = Hooker.hook(method, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.unhook() + }.also { hooks.add(it) } + } + + fun hookAllMethods(clazz: Class<*>, methodName: String, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hook(methodName, getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllConstructors(clazz: Class<*>, stage: String, callback: HookCallback): HookUnhook { + val hookAdapter = clazz.hookConstructor(getHookStageFromString(stage)) { + callback(ScriptHookCallback(it)) + } + + return { + hookAdapter.forEach { it.unhook() } + }.also { hooks.add(it) } + } + + fun hookAllMethods(className: String, methodName: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllMethods(it, methodName, stage, callback) } + + fun hookAllConstructors(className: String, stage: String, callback: HookCallback) + = findClassSafe(className)?.let { hookAllConstructors(it, stage, callback) } +}+ \ 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 @@ -0,0 +1,141 @@ +package me.rhunk.snapenhance.core.ui + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.shapes.Shape +import android.view.Gravity +import android.view.View +import android.widget.Switch +import android.widget.TextView +import me.rhunk.snapenhance.common.Constants +import kotlin.random.Random + +fun View.applyTheme(componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { + ViewAppearanceHelper.applyTheme(this, componentWidth, hasRadius, isAmoled) +} + +private val foregroundDrawableListTag = Random.nextInt(0x7000000, 0x7FFFFFFF) + +@Suppress("UNCHECKED_CAST") +private fun View.getForegroundDrawables(): MutableMap<String, Drawable> { + return getTag(foregroundDrawableListTag) as? MutableMap<String, Drawable> + ?: mutableMapOf<String, Drawable>().also { + setTag(foregroundDrawableListTag, it) + } +} + +private fun View.updateForegroundDrawable() { + foreground = ShapeDrawable(object: Shape() { + override fun draw(canvas: Canvas, paint: Paint) { + getForegroundDrawables().forEach { (_, drawable) -> + drawable.draw(canvas) + } + } + }) +} + +fun View.removeForegroundDrawable(tag: String) { + getForegroundDrawables().remove(tag)?.let { + updateForegroundDrawable() + } +} + +fun View.addForegroundDrawable(tag: String, drawable: Drawable) { + getForegroundDrawables()[tag] = drawable + updateForegroundDrawable() +} + + +object ViewAppearanceHelper { + @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", + "ClickableViewAccessibility" + ) + private var sigColorTextPrimary: Int = 0 + private var sigColorBackgroundSurface: Int = 0 + + private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { + if (!hasRadius) return ColorDrawable(color) + //FIXME: hardcoded radius + return ShapeDrawable().apply { + paint.color = color + shape = android.graphics.drawable.shapes.RoundRectShape( + floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), + null, + null + ) + } + } + + @SuppressLint("DiscouragedApi") + fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { + val resources = component.context.resources + if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { + with(component.context.theme) { + sigColorTextPrimary = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + + sigColorBackgroundSurface = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + } + } + + val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", Constants.SNAPCHAT_PACKAGE_NAME) + val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 + + with(component) { + if (this is TextView) { + setTextColor(sigColorTextPrimary) + setShadowLayer(0F, 0F, 0F, 0) + gravity = Gravity.CENTER_VERTICAL + componentWidth?.let { width = it} + height = (150 * scalingFactor).toInt() + isAllCaps = false + textSize = 16f + typeface = resources.getFont(snapchatFontResId) + outlineProvider = null + setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) + } + 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) + } + } + + if (component is Switch) { + with(resources) { + component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() + } + component.trackTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#1d1d1d"), + Color.parseColor("#26bd49") + ) + ) + component.thumbTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#F5F5F5"), + Color.parseColor("#26bd49") + ) + ) + } + } + + fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert) +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewTagState.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewTagState.kt @@ -0,0 +1,20 @@ +package me.rhunk.snapenhance.core.ui + +import android.view.View +import kotlin.random.Random + +class ViewTagState { + private val tag = Random.nextInt(0x7000000, 0x7FFFFFFF) + + operator fun get(view: View) = hasState(view) + + private fun hasState(view: View): Boolean { + if (view.getTag(tag) != null) return true + view.setTag(tag, true) + return false + } + + fun removeState(view: View) { + view.setTag(tag, null) + } +}+ \ No newline at end of file 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 @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.ui.menu + +import me.rhunk.snapenhance.core.ModContext + +abstract class AbstractMenu { + lateinit var context: ModContext + + 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 @@ -0,0 +1,226 @@ +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 +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger +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 java.time.Instant + + +@SuppressLint("DiscouragedApi") +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 chatActionMenuItemMargin by lazy { + context.androidContext.resources.getDimensionPixelSize( + context.androidContext.resources.getIdentifier( + "chat_action_menu_item_margin", + "dimen", + Constants.SNAPCHAT_PACKAGE_NAME + ) + ) + } + + private val actionMenuItemHeight by lazy { + context.androidContext.resources.getDimensionPixelSize( + context.androidContext.resources.getIdentifier( + "action_menu_item_height", + "dimen", + Constants.SNAPCHAT_PACKAGE_NAME + ) + ) + } + + private fun createContainer(viewGroup: ViewGroup): LinearLayout { + val parent = viewGroup.parent.parent as ViewGroup + + return LinearLayout(viewGroup.context).apply layout@{ + orientation = LinearLayout.VERTICAL + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + applyTheme(parent.width, true) + setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) + } + } + } + + private fun copyAlertDialog(context: Context, title: String, text: String) { + ViewAppearanceHelper.newAlertDialogBuilder(context).apply { + setTitle(title) + setMessage(text) + setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + setNegativeButton("Copy") { _, _ -> + this@ChatActionMenu.context.copyToClipboard(text, title) + } + }.show() + } + + private val lastFocusedMessage + 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 + //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 messaging = context.feature(Messaging::class) + val messageLogger = context.feature(MessageLogger::class) + + val buttonContainer = createContainer(viewGroup) + + val injectButton = { button: Button -> + if (buttonContainer.childCount > 0) { + buttonContainer.addView(View(viewGroup.context).apply { + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + height = 1 + } + setBackgroundColor(0x1A000000) + }) + } + + with(button) { + applyTheme(parent.width, true) + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + height = actionMenuItemHeight + defaultGap + } + buttonContainer.addView(this) + } + } + + if (context.config.downloader.chatDownloadContextMenu.get()) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.preview_button"] + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { feature(MediaDownloader::class).onMessageActionMenu(true) } + } + }) + + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.download_button"] + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { + feature(MediaDownloader::class).onMessageActionMenu(false) + } + } + }) + } + + //delete logged message button + if (context.config.messaging.messageLogger.get()) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation["chat_action_menu.delete_logged_message_button"] + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { + messageLogger.deleteMessage(messaging.openedConversationUUID.toString(), messaging.lastFocusedMessageId) + } + } + }) + } + + if (context.isDeveloper) { + parent.addView(createContainer(viewGroup).apply { + val debugText = StringBuilder() + + setOnClickListener { + this@ChatActionMenu.context.copyToClipboard(debugText.toString(), "debug") + } + + addView(TextView(viewGroup.context).apply { + setPadding(20, 20, 20, 20) + textSize = 10f + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + val arroyoMessage = lastFocusedMessage ?: return@addOnLayoutChangeListener + text = debugText.apply { + runCatching { + clear() + append("sender_id: ${arroyoMessage.senderId}\n") + append("client_id: ${arroyoMessage.clientMessageId}, server_id: ${arroyoMessage.serverMessageId}\n") + append("conversation_id: ${arroyoMessage.clientConversationId}\n") + append("arroyo_content_type: ${ContentType.fromId(arroyoMessage.contentType)} (${arroyoMessage.contentType})\n") + append("parsed_content_type: ${ + ContentType.fromMessageContainer( + ProtoReader(arroyoMessage.messageContent!!).followPath(4, 4) + ).let { "$it (${it?.id})" }}\n") + append("creation_timestamp: ${arroyoMessage.creationTimestamp} (${Instant.ofEpochMilli(arroyoMessage.creationTimestamp)})\n") + append("read_timestamp: ${arroyoMessage.readTimestamp} (${Instant.ofEpochMilli(arroyoMessage.readTimestamp)})\n") + append("is_messagelogger_deleted: ${messageLogger.isMessageDeleted(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong())}\n") + append("is_messagelogger_stored: ${messageLogger.getMessageObject(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong()) != null}\n") + }.onFailure { + debugText.append("Error: $it\n") + } + }.toString().trimEnd() + } + }) + + // action buttons + addView(LinearLayout(viewGroup.context).apply { + orientation = LinearLayout.HORIZONTAL + addView(Button(viewGroup.context).apply { + text = "Show Deleted Message Object" + setOnClickListener { + val message = lastFocusedMessage ?: return@setOnClickListener + copyAlertDialog( + viewGroup.context, + "Deleted Message Object", + messageLogger.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.toString() + ?: "null" + ) + } + }) + }) + }) + } + + parent.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 @@ -0,0 +1,255 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.content.DialogInterface +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.Button +import android.widget.CompoundButton +import android.widget.Switch +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.ConversationMessage +import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.database.impl.UserConversationLink +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +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 java.net.HttpURLConnection +import java.net.URL +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class FriendFeedInfoMenu : AbstractMenu() { + private fun getImageDrawable(url: String): Drawable { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connect() + val input = connection.inputStream + return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) + } + + private fun formatDate(timestamp: Long): String? { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) + } + + private fun showProfileInfo(profile: FriendInfo) { + var icon: Drawable? = null + try { + if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { + icon = getImageDrawable( + BitmojiSelfie.getBitmojiSelfie( + profile.bitmojiSelfieId.toString(), + profile.bitmojiAvatarId.toString(), + BitmojiSelfie.BitmojiSelfieType.THREE_D + )!! + ) + } + } catch (e: Throwable) { + context.log.error("Error loading bitmoji selfie", e) + } + val finalIcon = icon + val translation = context.translation.getCategory("profile_info") + + context.runOnUiThread { + val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + builder.setIcon(finalIcon) + builder.setTitle(profile.displayName ?: profile.username) + + val birthday = Calendar.getInstance() + birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 + + builder.setMessage(mapOf( + translation["first_created_username"] to profile.firstCreatedUsername, + translation["mutable_username"] to profile.mutableUsername, + translation["display_name"] to profile.displayName, + translation["added_date"] to formatDate(addedTimestamp), + null to birthday.getDisplayName( + Calendar.MONTH, + Calendar.LONG, + context.translation.loadedLocale + )?.let { + context.translation.format("profile_info.birthday", + "month" to it, + "day" to profile.birthday.toInt().toString()) + }, + translation["friendship"] to run { + translation.getCategory("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 { + translation.getCategory("snapchat_plus_state")[if (profile.postViewEmoji != null) "subscribed" else "not_subscribed"] + } + ).filterValues { it != null }.map { + line -> "${line.key?.let { "$it: " } ?: ""}${line.value}" + }.joinToString("\n")) + + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + builder.show() + } + } + + private fun showPreview(userId: String?, conversationId: String) { + //query message + val messageLogger = context.feature(MessageLogger::class) + val messages: List<ConversationMessage> = context.database.getMessagesFromConversationId( + conversationId, + context.config.messaging.messagePreviewLength.get() + )?.reversed() ?: emptyList() + + val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! + .map { context.database.getFriendInfo(it)!! } + .associateBy { it.userId!! } + + val messageBuilder = StringBuilder() + + messages.forEach { message -> + val sender = participants[message.senderId] + val protoReader = ( + messageLogger.takeIf { it.isEnabled }?.getMessageProto(conversationId, message.clientMessageId.toLong()) ?: ProtoReader(message.messageContent ?: return@forEach).followPath(4, 4) + ) ?: return@forEach + + val contentType = ContentType.fromMessageContainer(protoReader) ?: ContentType.fromId(message.contentType) + var messageString = if (contentType == ContentType.CHAT) { + protoReader.getString(2, 1) ?: return@forEach + } else { + contentType.name + } + + if (contentType == ContentType.SNAP) { + messageString = "\uD83D\uDFE5" //red square + if (message.readTimestamp > 0) { + messageString += " \uD83D\uDC40 " //eyes + messageString += DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(message.readTimestamp)) + } + } + + var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] + + if (displayUsername.length > 12) { + displayUsername = displayUsername.substring(0, 13) + "... " + } + + messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") + } + + val targetPerson = if (userId == null) null else participants[userId] + + targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { + val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() + messageBuilder.append("\n") + .append("\uD83D\uDD25 ") //fire emoji + .append( + context.translation.format("conversation_preview.streak_expiration", + "day" to (timeSecondDiff / 60 / 24).toString(), + "hour" to (timeSecondDiff / 60 % 24).toString(), + "minute" to (timeSecondDiff % 60).toString() + )) + } + + messages.lastOrNull()?.let { + messageBuilder + .append("\n\n") + .append(context.translation.format("conversation_preview.total_messages", "count" to it.serverMessageId.toString())) + .append("\n") + } + + //alert dialog + val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + builder.setTitle(context.translation["conversation_preview.title"]) + builder.setMessage(messageBuilder.toString()) + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + targetPerson?.let { + builder.setNegativeButton(context.translation["modal_option.profile_info"]) { _, _ -> + context.executeAsync { showProfileInfo(it) } + } + } + builder.show() + } + + private fun getCurrentConversationInfo(): Pair<String, String?> { + val messaging = context.feature(Messaging::class) + val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString() + + //mapped conversation fetch (may not work with legacy sc versions) + messaging.lastFetchGroupConversationUUID?.let { + context.database.getFeedEntryByConversationId(it.toString())?.let { friendFeedInfo -> + val participantSize = friendFeedInfo.participantsSize + return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null + } + throw IllegalStateException("No conversation found") + } + + //old conversation fetch + val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) { + val conversation: UserConversationLink = context.database.getConversationLinkFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found") + conversation.clientConversationId!!.trim().lowercase() + } else { + messaging.lastFetchConversationUUID.toString() + } + + return conversationId to focusedConversationTargetUser + } + + private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { + val switch = Switch(context.androidContext) + switch.text = context.translation[text] + switch.isChecked = isChecked() + switch.applyTheme(hasRadius = true) + switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> + toggle(checked) + } + viewConsumer(switch) + } + + fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { + val modContext = context + + val friendFeedMenuOptions by context.config.userInterface.friendFeedMenuButtons + if (friendFeedMenuOptions.isEmpty()) return + + 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) + } + + modContext.features.getRuleFeatures().forEach { ruleFeature -> + if (!friendFeedMenuOptions.contains(ruleFeature.ruleType.key)) return@forEach + + val ruleState = ruleFeature.getRuleState() ?: return@forEach + createToggleFeature(viewConsumer, + ruleFeature.ruleType.translateOptionKey(ruleState.key), + { ruleFeature.getState(conversationId) }, + { ruleFeature.setState(conversationId, it) } + ) + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,146 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.annotation.SuppressLint +import android.view.Gravity +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 java.lang.reflect.Modifier + +@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 newChatString by lazy { + context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) + } + + @SuppressLint("ResourceType") + override fun asyncOnActivityCreate() { + friendFeedInfoMenu.context = context + operaContextActionMenu.context = context + chatActionMenu.context = context + settingMenu.context = context + settingsGearInjector.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) + + context.event.subscribe(AddViewEvent::class) { event -> + val originalAddView: (View) -> Unit = { + event.adapter.invokeOriginal(arrayOf(it, -1, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + ) + } + + val viewGroup: ViewGroup = event.parent + val childView: View = event.view + operaContextActionMenu.inject(event.parent, childView) + + if (event.parent.id == componentsHolder && childView.id == feedNewChat) { + settingsGearInjector.inject(event.parent, childView) + 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) + return@subscribe + } + + //TODO: inject in group chat menus + if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchGroupConversationUUID != null) { + val injectedLayout = LinearLayout(childView.context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.BOTTOM + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + addView(childView) + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + messaging.lastFetchGroupConversationUUID = null + } + }) + } + + val viewList = mutableListOf<View>() + context.runOnUiThread { + friendFeedInfoMenu.inject(injectedLayout) { view -> + view.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 5, 0, 5) + } + viewList.add(view) + } + + viewList.add(View(injectedLayout.context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 30 + ) + }) + + viewList.reversed().forEach { injectedLayout.addView(it, 0) } + } + + event.view = injectedLayout + } + + if (viewGroup is LinearLayout && viewGroup.id == actionSheetItemsContainerLayoutId) { + val itemStringInterface by lazy { + childView.javaClass.declaredFields.filter { + !it.type.isPrimitive && Modifier.isAbstract(it.type.modifiers) + }.map { + runCatching { + it.isAccessible = true + it[childView] + }.getOrNull() + }.firstOrNull() + } + + //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) + viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + viewTagState.removeState(viewGroup) + } + }) + viewTagState[viewGroup] + return@subscribe + } + if (messaging.lastFetchConversationUUID == null || messaging.lastFetchConversationUserUUID == null) return@subscribe + + //filter by the slot index + if (viewGroup.getChildCount() != context.config.userInterface.friendFeedMenuPosition.get()) return@subscribe + if (viewTagState[viewGroup]) return@subscribe + friendFeedInfoMenu.inject(viewGroup, originalAddView) + } + } + } +}+ \ No newline at end of file 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 @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +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 + +@SuppressLint("DiscouragedApi") +class OperaContextActionMenu : AbstractMenu() { + private val contextCardsScrollView by lazy { + context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) + } + + /* + LinearLayout : + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + */ + private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { + if (viewGroup !is LinearLayout) return false + val children = ArrayList<View>() + for (i in 0 until viewGroup.getChildCount()) + children.add(viewGroup.getChildAt(i)) + return if (children.any { view: View? -> view !is LinearLayout }) + false + else children.map { view: View -> view as LinearLayout } + .any { linearLayout: LinearLayout -> + val viewChildren = ArrayList<View>() + for (i in 0 until linearLayout.childCount) viewChildren.add( + linearLayout.getChildAt( + i + ) + ) + viewChildren.any { viewChild: View -> + viewChild.javaClass.name.endsWith("SnapFontTextView") + } + } + } + + @SuppressLint("SetTextI18n") + fun inject(viewGroup: ViewGroup, childView: View) { + 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 + + val linearLayout = LinearLayout(childView.getContext()) + linearLayout.orientation = LinearLayout.VERTICAL + linearLayout.gravity = Gravity.CENTER + linearLayout.layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val translation = context.translation + val mediaDownloader = context.feature(MediaDownloader::class) + + linearLayout.addView(Button(childView.getContext()).apply { + text = translation["opera_context_menu.download"] + setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync() } + applyTheme(isAmoled = false) + }) + + if (context.isDeveloper) { + linearLayout.addView(Button(childView.getContext()).apply { + text = "Show debug info" + setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } + applyTheme(isAmoled = false) + }) + } + + (childView 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 @@ -0,0 +1,83 @@ +package me.rhunk.snapenhance.core.ui.menu.impl + +import android.annotation.SuppressLint +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 + + +@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) + } + } + + @SuppressLint("SetTextI18n", "ClickableViewAccessibility") + fun inject(parent: ViewGroup, child: View) { + val firstView = (child as ViewGroup).getChildAt(0) + + child.clipChildren = false + child.addView(FrameLayout(parent.context).apply { + layoutParams = FrameLayout.LayoutParams(firstView.layoutParams.width, firstView.layoutParams.height).apply { + y = 0f + x = -(ngsHovaHeaderSearchIconBackgroundMarginLeft + firstView.layoutParams.width).toFloat() + } + + isClickable = true + + setOnClickListener { + /* val intent = Intent().apply { + setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity") + putExtra("route", "features") + } + context.startActivity(intent)*/ + this@SettingsGearInjector.context.bridgeClient.openSettingsOverlay() + } + + parent.setOnTouchListener { _, event -> + if (child.visibility == View.INVISIBLE || child.alpha == 0F) return@setOnTouchListener false + + val viewLocation = IntArray(2) + getLocationOnScreen(viewLocation) + + val x = event.rawX - viewLocation[0] + val y = event.rawY - viewLocation[1] + + if (x > 0 && x < width && y > 0 && y < height) { + performClick() + } + + false + } + backgroundTintList = firstView.backgroundTintList + background = firstView.background + + addView(ImageView(context).apply { + layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 17).apply { + gravity = android.view.Gravity.CENTER + } + setImageDrawable(settingsSvg) + headerButtonOpaqueIconTint?.let { + imageTintList = it + } + }) + }) + } +}+ \ No newline at end of file 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 @@ -0,0 +1,29 @@ +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/CallbackBuilder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/CallbackBuilder.kt @@ -1,9 +1,9 @@ package me.rhunk.snapenhance.core.util import de.robv.android.xposed.XC_MethodHook -import me.rhunk.snapenhance.hook.HookAdapter -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker +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.lang.reflect.Constructor import java.lang.reflect.Field import java.lang.reflect.Modifier diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SQLiteDatabaseHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SQLiteDatabaseHelper.kt @@ -1,31 +0,0 @@ -package me.rhunk.snapenhance.core.util - -import android.annotation.SuppressLint -import android.database.sqlite.SQLiteDatabase -import me.rhunk.snapenhance.core.Logger - -object SQLiteDatabaseHelper { - @SuppressLint("Range") - fun createTablesFromSchema(sqLiteDatabase: SQLiteDatabase, databaseSchema: Map<String, List<String>>) { - databaseSchema.forEach { (tableName, columns) -> - sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") - - val cursor = sqLiteDatabase.rawQuery("PRAGMA table_info($tableName)", null) - val existingColumns = mutableListOf<String>() - while (cursor.moveToNext()) { - existingColumns.add(cursor.getString(cursor.getColumnIndex("name")) + " " + cursor.getString(cursor.getColumnIndex("type"))) - } - cursor.close() - - val newColumns = columns.filter { - existingColumns.none { existingColumn -> it.startsWith(existingColumn) } - } - - if (newColumns.isEmpty()) return@forEach - - Logger.directDebug("Schema for table $tableName has changed") - sqLiteDatabase.execSQL("DROP TABLE $tableName") - sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SerializableDataObject.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/SerializableDataObject.kt @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.core.util - -import com.google.gson.Gson -import com.google.gson.GsonBuilder - -open class SerializableDataObject { - companion object { - val gson: Gson = GsonBuilder().create() - - inline fun <reified T : SerializableDataObject> fromJson(json: String): T { - return gson.fromJson(json, T::class.java) - } - - inline fun <reified T : SerializableDataObject> fromJson(json: String, type: Class<T>): T { - return gson.fromJson(json, type) - } - } - - fun toJson(): String { - return gson.toJson(this) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/HttpServer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/download/HttpServer.kt @@ -1,139 +0,0 @@ -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.core.Logger -import java.io.BufferedReader -import java.io.InputStream -import java.io.InputStreamReader -import java.io.PrintWriter -import java.net.ServerSocket -import java.net.Socket -import java.net.SocketException -import java.util.Locale -import java.util.StringTokenizer -import java.util.concurrent.ConcurrentHashMap -import kotlin.random.Random - -class HttpServer( - private val timeout: Int = 10000 -) { - val port = Random.nextInt(10000, 65535) - - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private var timeoutJob: Job? = null - private var socketJob: Job? = null - - private val cachedData = ConcurrentHashMap<String, Pair<InputStream, Long>>() - private var serverSocket: ServerSocket? = null - - fun ensureServerStarted(callback: HttpServer.() -> Unit) { - if (serverSocket != null && !serverSocket!!.isClosed) { - callback(this) - return - } - - coroutineScope.launch(Dispatchers.IO) { - Logger.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()) - Logger.directDebug("http server closed due to timeout") - runCatching { - socketJob?.cancel() - socket.close() - serverSocket?.close() - }.onFailure { - Logger.directError("failed to close socket", it) - } - } - } - } catch (e: SocketException) { - Logger.directDebug("http server timed out") - break; - } catch (e: Throwable) { - Logger.directError("failed to handle request", e) - } - } - }.also { socketJob = it } - } - - fun close() { - serverSocket?.close() - } - - fun putDownloadableContent(inputStream: InputStream, size: Long): String { - val key = System.nanoTime().toString(16) - cachedData[key] = inputStream to size - return "http://127.0.0.1:$port/$key" - } - - private fun handleRequest(socket: Socket) { - val reader = BufferedReader(InputStreamReader(socket.getInputStream())) - val outputStream = socket.getOutputStream() - val writer = PrintWriter(outputStream) - val line = reader.readLine() ?: return - fun close() { - runCatching { - reader.close() - writer.close() - outputStream.close() - socket.close() - }.onFailure { - 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.directDebug("[http-server:${port}] $method $fileRequested") - - if (method != "GET") { - with(writer) { - println("HTTP/1.1 501 Not Implemented") - println("Content-type: " + "application/octet-stream") - println("Content-length: " + 0) - println() - flush() - } - close() - return - } - 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() - } - close() - return - } - val requestedData = cachedData[fileRequested]!! - with(writer) { - println("HTTP/1.1 200 OK") - println("Content-type: " + "application/octet-stream") - println("Content-length: " + requestedData.second) - println() - flush() - } - requestedData.first.copyTo(outputStream) - outputStream.flush() - cachedData.remove(fileRequested) - close() - } -}- \ No newline at end of file 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 @@ -1,68 +0,0 @@ -package me.rhunk.snapenhance.core.util.download - -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.Logger -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.InputStream -import java.util.Base64 - -object RemoteMediaResolver { - 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<String, String>() - - private val okHttpClient = OkHttpClient.Builder() - .followRedirects(true) - .retryOnConnectionFailure(true) - .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS) - .addInterceptor { chain -> - val request = chain.request() - val requestUrl = request.url.toString() - - if (urlCache.containsKey(requestUrl)) { - val cachedUrl = urlCache[requestUrl]!! - return@addInterceptor chain.proceed(request.newBuilder().url(cachedUrl).build()) - } - - chain.proceed(request).apply { - val responseUrl = this.request.url.toString() - if (responseUrl.startsWith("https://cf-st.sc-cdn.net")) { - urlCache[requestUrl] = responseUrl - } - } - } - .build() - - private fun newResolveRequest(protoKey: ByteArray) = Request.Builder() - .url(BOLT_HTTP_RESOLVER_URL + "/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey)) - .addHeader("User-Agent", Constants.USER_AGENT) - .build() - - /** - * Download bolt media with memory allocation - */ - fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }): ByteArray? { - okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response -> - if (!response.isSuccessful) { - Logger.directDebug("Unexpected code $response") - return null - } - return decryptionCallback(response.body.byteStream()).readBytes() - } - } - - fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }, resultCallback: (InputStream) -> Unit) { - okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response -> - if (!response.isSuccessful) { - throw Throwable("invalid response ${response.code}") - } - resultCallback( - decryptionCallback( - response.body.byteStream() - ) - ) - } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/export/MessageExporter.kt @@ -1,325 +0,0 @@ -package me.rhunk.snapenhance.core.util.export - -import android.os.Environment -import android.util.Base64InputStream -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.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.download.RemoteMediaResolver -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -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.wrapper.impl.Message -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.impl.downloader.decoder.AttachmentType -import me.rhunk.snapenhance.features.impl.downloader.decoder.MessageDecoder -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.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<ContentType>? = null, - private val printLog: (String) -> Unit = {}, -) { - private lateinit var conversationParticipants: Map<String, FriendInfo> - private lateinit var messages: List<Message> - - fun readMessages(messages: List<Message>) { - 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() - } - - suspend fun exportHtml(output: OutputStream) { - val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } - val mediaFiles = Collections.synchronizedMap(mutableMapOf<String, Pair<FileType, File>>()) - 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) - }) { - it.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(""" - <!DOCTYPE html> - <html> - <head> - <meta charset="UTF-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title></title> - </head> - """.trimIndent().toByteArray()) - - output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray()) - - mediaFiles.forEach { (key, filePair) -> - output.write("<div class=\"media-$key\"><!-- ".toByteArray()) - - val deflateInputStream = DeflaterInputStream(filePair.second.inputStream(), Deflater(Deflater.BEST_COMPRESSION, true)) - val base64InputStream = XposedHelpers.newInstance( - Base64InputStream::class.java, - deflateInputStream, - android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, - true - ) as InputStream - base64InputStream.copyTo(output) - deflateInputStream.close() - - output.write(" --></div>\n".toByteArray()) - output.flush() - updateProgress("wrote") - } - printLog("writing json conversation data...") - - //write the json file - output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray()) - exportJson(output) - output.write("</script>\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("<script>".toByteArray()) - apkFile.getInputStream(entry).copyTo(output) - output.write("</script>\n".toByteArray()) - } - - //export avenir next font - apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> - val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) - output.write(""" - <style> - @font-face { - font-family: 'Avenir Next'; - src: url('data:font/truetype;charset=utf-8;base64, $encodedFontData'); - font-weight: normal; - font-style: normal; - } - </style> - """.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("</html>".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<String, Int>() - - 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<SnapUUID>) { - 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) { - val output = FileOutputStream(outputFile) - - when (exportFormat) { - ExportFormat.HTML -> exportHtml(output) - ExportFormat.JSON -> exportJson(output) - ExportFormat.TEXT -> exportText(output) - } - - output.close() - } -}- \ No newline at end of file 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 @@ -0,0 +1,76 @@ +package me.rhunk.snapenhance.core.util.hook + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import java.lang.reflect.Member +import java.util.function.Consumer + +@Suppress("UNCHECKED_CAST") +class HookAdapter( + private val methodHookParam: XC_MethodHook.MethodHookParam<*> +) { + fun <T : Any> thisObject(): T { + return methodHookParam.thisObject as T + } + + fun <T : Any> nullableThisObject(): T? { + return methodHookParam.thisObject as T? + } + + fun method(): Member { + return methodHookParam.method + } + + fun <T : Any> arg(index: Int): T { + return methodHookParam.args[index] as T + } + + fun <T : Any> argNullable(index: Int): T? { + return methodHookParam.args.getOrNull(index) as T? + } + + fun setArg(index: Int, value: Any?) { + if (index < 0 || index >= methodHookParam.args.size) return + methodHookParam.args[index] = value + } + + fun args(): Array<Any?> { + return methodHookParam.args + } + + fun getResult(): Any? { + return methodHookParam.result + } + + fun setResult(result: Any?) { + methodHookParam.result = result + } + + fun setThrowable(throwable: Throwable) { + methodHookParam.throwable = throwable + } + + fun throwable(): Throwable? { + return methodHookParam.throwable + } + + fun invokeOriginal(): Any? { + return XposedBridge.invokeOriginalMethod(method(), thisObject(), args()) + } + + fun invokeOriginal(args: Array<Any>): Any? { + return XposedBridge.invokeOriginalMethod(method(), thisObject(), args) + } + + fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) { + invokeOriginalSafe(args(), errorCallback) + } + + fun invokeOriginalSafe(args: Array<Any?>, errorCallback: Consumer<Throwable>) { + runCatching { + setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args)) + }.onFailure { + errorCallback.accept(it) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookStage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/HookStage.kt @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.core.util.hook + +enum class HookStage { + BEFORE, + AFTER +}+ \ No newline at end of file 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 @@ -0,0 +1,157 @@ +package me.rhunk.snapenhance.core.util.hook + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import java.lang.reflect.Member +import java.lang.reflect.Method + +object Hooker { + inline fun newMethodHook( + stage: HookStage, + crossinline consumer: (HookAdapter) -> Unit, + crossinline filter: ((HookAdapter) -> Boolean) = { true } + ): XC_MethodHook { + return if (stage == HookStage.BEFORE) object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam<*>) { + HookAdapter(param).takeIf(filter)?.also(consumer) + } + } else object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam<*>) { + HookAdapter(param).takeIf(filter)?.also(consumer) + } + } + } + + inline fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + crossinline filter: (HookAdapter) -> Boolean, + noinline consumer: (HookAdapter) -> Unit + ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) + + inline fun hook( + member: Member, + stage: HookStage, + crossinline filter: ((HookAdapter) -> Boolean), + crossinline consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) + } + + fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer) + + fun hook( + member: Member, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return hook(member, stage, { true }, consumer) + } + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit + ) { + XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) + } + + inline fun hookObjectMethod( + clazz: Class<*>, + instance: Any, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() + hook(clazz, methodName, stage) { param-> + if (param.nullableThisObject<Any>().let { + if (it == null) unhooks.forEach { u -> u.unhook() } + it != instance + }) return@hook + hookConsumer(param) + }.also { unhooks.addAll(it) } + } + + inline fun ephemeralHook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() + hook(clazz, methodName, stage) { param-> + hookConsumer(param) + unhooks.forEach{ it.unhook() } + }.also { unhooks.addAll(it) } + } + + inline fun ephemeralHookObjectMethod( + clazz: Class<*>, + instance: Any, + methodName: String, + stage: HookStage, + crossinline hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() + hook(clazz, methodName, stage) { param-> + if (param.nullableThisObject<Any>() != instance) return@hook + unhooks.forEach { it.unhook() } + hookConsumer(param) + }.also { unhooks.addAll(it) } + } +} + +fun Class<*>.hookConstructor( + stage: HookStage, + consumer: (HookAdapter) -> Unit +) = Hooker.hookConstructor(this, stage, consumer) + +fun Class<*>.hookConstructor( + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit +) = Hooker.hookConstructor(this, stage, filter, consumer) + +fun Class<*>.hook( + methodName: String, + stage: HookStage, + consumer: (HookAdapter) -> Unit +): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer) + +fun Class<*>.hook( + methodName: String, + stage: HookStage, + filter: (HookAdapter) -> Boolean, + consumer: (HookAdapter) -> Unit +): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer) + +fun Member.hook( + stage: HookStage, + consumer: (HookAdapter) -> Unit +): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer) + +fun Member.hook( + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit +): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer) + +fun Array<Method>.hookAll(stage: HookStage, param: (HookAdapter) -> Unit) { + filter { it.declaringClass != Object::class.java }.forEach { + it.hook(stage, param) + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidCompatExtensions.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidCompatExtensions.kt @@ -1,13 +0,0 @@ -package me.rhunk.snapenhance.core.util.ktx - -import android.content.pm.PackageManager -import android.content.pm.PackageManager.ApplicationInfoFlags -import android.os.Build - -fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getApplicationInfo(packageName, ApplicationInfoFlags.of(flags.toLong())) - } else { - @Suppress("DEPRECATION") - getApplicationInfo(packageName, flags) - } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/DbCursorExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/DbCursorExt.kt @@ -1,37 +0,0 @@ -package me.rhunk.snapenhance.core.util.ktx - -import android.database.Cursor - -fun Cursor.getStringOrNull(columnName: String): String? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getString(columnIndex) -} - -fun Cursor.getIntOrNull(columnName: String): Int? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getInt(columnIndex) -} - -fun Cursor.getInteger(columnName: String) = getIntOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") -fun Cursor.getLong(columnName: String) = getLongOrNull(columnName) ?: throw NullPointerException("Column $columnName is null") - -fun Cursor.getBlobOrNull(columnName: String): ByteArray? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getBlob(columnIndex) -} - - -fun Cursor.getLongOrNull(columnName: String): Long? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getLong(columnIndex) -} - -fun Cursor.getDoubleOrNull(columnName: String): Double? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getDouble(columnIndex) -} - -fun Cursor.getFloatOrNull(columnName: String): Float? { - val columnIndex = getColumnIndex(columnName) - return if (columnIndex == -1) null else getFloat(columnIndex) -}- \ No newline at end of file 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 @@ -0,0 +1,139 @@ +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 me.rhunk.snapenhance.common.logger.AbstractLogger +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.util.Locale +import java.util.StringTokenizer +import java.util.concurrent.ConcurrentHashMap +import kotlin.random.Random + +class HttpServer( + private val timeout: Int = 10000 +) { + val port = Random.nextInt(10000, 65535) + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + private var timeoutJob: Job? = null + private var socketJob: Job? = null + + private val cachedData = ConcurrentHashMap<String, Pair<InputStream, Long>>() + private var serverSocket: ServerSocket? = null + + fun ensureServerStarted(callback: HttpServer.() -> Unit) { + if (serverSocket != null && !serverSocket!!.isClosed) { + callback(this) + return + } + + 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) + } + } + } + } catch (e: SocketException) { + AbstractLogger.directDebug("http server timed out") + break; + } catch (e: Throwable) { + AbstractLogger.directError("failed to handle request", e) + } + } + }.also { socketJob = it } + } + + fun close() { + serverSocket?.close() + } + + fun putDownloadableContent(inputStream: InputStream, size: Long): String { + val key = System.nanoTime().toString(16) + cachedData[key] = inputStream to size + return "http://127.0.0.1:$port/$key" + } + + private fun handleRequest(socket: Socket) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val outputStream = socket.getOutputStream() + val writer = PrintWriter(outputStream) + val line = reader.readLine() ?: return + fun close() { + runCatching { + reader.close() + writer.close() + outputStream.close() + socket.close() + }.onFailure { + AbstractLogger.directError("failed to close socket", it) + } + } + val parse = StringTokenizer(line) + val method = parse.nextToken().uppercase(Locale.getDefault()) + var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) + AbstractLogger.directDebug("[http-server:${port}] $method $fileRequested") + + if (method != "GET") { + with(writer) { + println("HTTP/1.1 501 Not Implemented") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() + } + close() + return + } + 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() + } + close() + return + } + val requestedData = cachedData[fileRequested]!! + with(writer) { + println("HTTP/1.1 200 OK") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + requestedData.second) + println() + flush() + } + requestedData.first.copyTo(outputStream) + outputStream.flush() + cachedData.remove(fileRequested) + close() + } +}+ \ No newline at end of file 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 @@ -0,0 +1,87 @@ +package me.rhunk.snapenhance.core.util.media + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.media.MediaDataSource +import android.media.MediaMetadataRetriever +import me.rhunk.snapenhance.common.data.FileType +import java.io.File + +object PreviewUtils { + fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { + if (!isVideo) { + return BitmapFactory.decodeByteArray(data, 0, data.size) + } + return MediaMetadataRetriever().apply { + setDataSource(object : MediaDataSource() { + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int + ): Int { + var newSize = size + val length = data.size + if (position >= length) { + return -1 + } + if (position + newSize > length) { + newSize = length - position.toInt() + } + System.arraycopy(data, position.toInt(), buffer, offset, newSize) + return newSize + } + + override fun getSize(): Long { + return data.size.toLong() + } + override fun close() {} + }) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } + + fun createPreviewFromFile(file: File): Bitmap? { + return if (FileType.fromFile(file).isVideo) { + MediaMetadataRetriever().apply { + setDataSource(file.absolutePath) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } else { + BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options()) + } + } + + 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 mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { + val biggestBitmap = if (originalMedia.width * originalMedia.height > overlayLayer.width * overlayLayer.height) originalMedia else overlayLayer + val smallestBitmap = if (biggestBitmap == originalMedia) overlayLayer else originalMedia + + val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config) + + with(Canvas(mergedBitmap)) { + val scaleMatrix = Matrix().apply { + postScale(biggestBitmap.width.toFloat() / smallestBitmap.width.toFloat(), biggestBitmap.height.toFloat() / smallestBitmap.height.toFloat()) + } + + if (biggestBitmap == originalMedia) { + drawBitmap(originalMedia, 0f, 0f, null) + drawBitmap(overlayLayer, scaleMatrix, null) + } else { + drawBitmap(originalMedia, scaleMatrix, null) + drawBitmap(overlayLayer, 0f, 0f, null) + } + } + + return mergedBitmap + } +} 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 @@ -1,67 +0,0 @@ -package me.rhunk.snapenhance.core.util.protobuf - - -typealias WireCallback = EditorContext.() -> Unit - -class EditorContext( - private val wires: MutableMap<Int, MutableList<Wire>> -) { - fun clear() { - wires.clear() - } - fun addWire(wire: Wire) { - wires.getOrPut(wire.id) { mutableListOf() }.add(wire) - } - 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.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)) - fun addFixed32(id: Int, value: Int) = addWire(Wire(id, WireType.FIXED32, value)) - - fun firstOrNull(id: Int) = wires[id]?.firstOrNull() - fun getOrNull(id: Int) = wires[id] - fun get(id: Int) = wires[id]!! - - fun remove(id: Int) = wires.remove(id) - fun remove(id: Int, index: Int) = wires[id]?.removeAt(index) -} - -class ProtoEditor( - private var buffer: ByteArray -) { - fun edit(vararg path: Int, callback: WireCallback) { - buffer = writeAtPath(path, 0, ProtoReader(buffer), callback) - } - - private fun writeAtPath(path: IntArray, currentIndex: Int, rootReader: ProtoReader, wireToWriteCallback: WireCallback): ByteArray { - val id = path.getOrNull(currentIndex) - val output = ProtoWriter() - val wires = sortedMapOf<Int, MutableList<Wire>>() - - rootReader.forEach { wireId, value -> - wires.putIfAbsent(wireId, mutableListOf()) - if (id != null && wireId == id) { - val childReader = rootReader.followPath(id) - if (childReader == null) { - wires.getOrPut(wireId) { mutableListOf() }.add(value) - return@forEach - } - wires[wireId]!!.add(Wire(wireId, WireType.CHUNK, writeAtPath(path, currentIndex + 1, childReader, wireToWriteCallback))) - return@forEach - } - wires[wireId]!!.add(value) - } - - if (currentIndex == path.size) { - wireToWriteCallback(EditorContext(wires)) - } - - wires.values.flatten().forEach(output::addWire) - - return output.toByteArray() - } - - fun toByteArray() = buffer -}- \ No newline at end of file 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 @@ -1,255 +0,0 @@ -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) { - private var offset: Int = 0 - private val values = mutableMapOf<Int, MutableList<Wire>>() - - init { - read() - } - - fun getBuffer() = buffer - - private fun readByte() = buffer[offset++] - - private fun readVarInt(): Long { - var result = 0L - var shift = 0 - while (true) { - val b = readByte() - result = result or ((b.toLong() and 0x7F) shl shift) - if (b.toInt() and 0x80 == 0) { - break - } - shift += 7 - } - return result - } - - private fun read() { - while (offset < buffer.size) { - try { - val tag = readVarInt().toInt() - val id = tag ushr 3 - val type = WireType.fromValue(tag and 0x7) ?: break - val value = when (type) { - WireType.VARINT -> readVarInt() - WireType.FIXED64 -> { - val bytes = ByteArray(8) - for (i in 0..7) { - bytes[i] = readByte() - } - bytes - } - WireType.CHUNK -> { - val length = readVarInt().toInt() - val bytes = ByteArray(length) - for (i in 0 until length) { - bytes[i] = readByte() - } - bytes - } - WireType.START_GROUP -> { - val bytes = mutableListOf<Byte>() - while (true) { - val b = readByte() - if (b.toInt() == WireType.END_GROUP.value) { - break - } - bytes.add(b) - } - bytes.toByteArray() - } - WireType.FIXED32 -> { - val bytes = ByteArray(4) - for (i in 0..3) { - bytes[i] = readByte() - } - bytes - } - WireType.END_GROUP -> continue - } - values.getOrPut(id) { mutableListOf() }.add(Wire(id, type, value)) - } catch (t: Throwable) { - values.clear() - break - } - } - } - - fun followPath(vararg ids: Int, excludeLast: Boolean = false, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? { - var thisReader = this - ids.let { - if (excludeLast) { - it.sliceArray(0 until it.size - 1) - } else { - it - } - }.forEach { id -> - if (!thisReader.contains(id)) { - return null - } - thisReader = ProtoReader(thisReader.getByteArray(id) ?: return null) - } - if (reader != null) { - thisReader.reader() - } - return thisReader - } - - fun containsPath(vararg ids: Int): Boolean { - var thisReader = this - ids.forEach { id -> - if (!thisReader.contains(id)) { - return false - } - thisReader = ProtoReader(thisReader.getByteArray(id) ?: return false) - } - return true - } - - fun forEach(reader: (Int, Wire) -> Unit) { - values.forEach { (id, wires) -> - wires.forEach { wire -> - reader(id, wire) - } - } - } - - fun forEach(vararg id: Int, reader: ProtoReader.() -> Unit) { - followPath(*id)?.eachBuffer { _, buffer -> - ProtoReader(buffer).reader() - } - } - - fun eachBuffer(vararg ids: Int, reader: ProtoReader.() -> Unit) { - followPath(*ids, excludeLast = true)?.eachBuffer { id, buffer -> - if (id == ids.last()) { - ProtoReader(buffer).reader() - } - } - } - - fun eachBuffer(reader: (Int, ByteArray) -> Unit) { - values.forEach { (id, wires) -> - wires.forEach { wire -> - if (wire.type == WireType.CHUNK) { - reader(id, wire.value as ByteArray) - } - } - } - } - - fun contains(id: Int) = values.containsKey(id) - - fun getWire(id: Int) = values[id]?.firstOrNull() - fun getRawValue(id: Int) = getWire(id)?.value - fun getByteArray(id: Int) = getRawValue(id) as? ByteArray - fun getByteArray(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getByteArray(ids.last()) - fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8) - fun getString(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getString(ids.last()) - fun getVarInt(id: Int) = getRawValue(id) as? Long - fun getVarInt(vararg ids: Int) = followPath(*ids, excludeLast = true)?.getVarInt(ids.last()) - fun getCount(id: Int) = values[id]?.size ?: 0 - - fun getFixed64(id: Int): Long { - val bytes = getByteArray(id) ?: return 0L - var value = 0L - for (i in 0..7) { - value = value or ((bytes[i].toLong() and 0xFF) shl (i * 8)) - } - return value - } - - - fun getFixed32(id: Int): Int { - val bytes = getByteArray(id) ?: return 0 - var value = 0 - for (i in 0..3) { - value = value or ((bytes[i].toInt() and 0xFF) shl (i * 8)) - } - 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 ((7 - i) * 8)) - } - for (i in 8 .. 15) { - longs[1] = longs[1] or ((array[i].toLong() and 0xFF) shl ((15 - i) * 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 @@ -1,117 +0,0 @@ -package me.rhunk.snapenhance.core.util.protobuf - -import java.io.ByteArrayOutputStream - -class ProtoWriter { - private val stream: ByteArrayOutputStream = ByteArrayOutputStream() - - private fun writeVarInt(value: Int) { - var v = value - while (v and -0x80 != 0) { - stream.write(v and 0x7F or 0x80) - v = v ushr 7 - } - stream.write(v) - } - - private fun writeVarLong(value: Long) { - var v = value - while (v and -0x80L != 0L) { - stream.write((v and 0x7FL or 0x80L).toInt()) - v = v ushr 7 - } - stream.write(v.toInt()) - } - - fun addBuffer(id: Int, value: ByteArray) { - writeVarInt(id shl 3 or WireType.CHUNK.value) - writeVarInt(value.size) - stream.write(value) - } - - fun addVarInt(id: Int, value: Int) = addVarInt(id, value.toLong()) - - fun addVarInt(id: Int, value: Long) { - writeVarInt(id shl 3) - writeVarLong(value) - } - - fun addString(id: Int, value: String) = addBuffer(id, value.toByteArray()) - - fun addFixed32(id: Int, value: Int) { - writeVarInt(id shl 3 or WireType.FIXED32.value) - val bytes = ByteArray(4) - for (i in 0..3) { - bytes[i] = (value shr (i * 8)).toByte() - } - stream.write(bytes) - } - - fun addFixed64(id: Int, value: Long) { - writeVarInt(id shl 3 or WireType.FIXED64.value) - val bytes = ByteArray(8) - for (i in 0..7) { - bytes[i] = (value shr (i * 8)).toByte() - } - stream.write(bytes) - } - - fun from(id: Int, writer: ProtoWriter.() -> Unit) { - val writerStream = ProtoWriter() - writer(writerStream) - addBuffer(id, writerStream.stream.toByteArray()) - } - - fun from(vararg ids: Int, writer: ProtoWriter.() -> Unit) { - val writerStream = ProtoWriter() - writer(writerStream) - var stream = writerStream.stream.toByteArray() - ids.reversed().forEach { id -> - with(ProtoWriter()) { - addBuffer(id, stream) - stream = this.stream.toByteArray() - } - } - stream.let(this.stream::write) - } - - fun addWire(wire: Wire) { - writeVarInt(wire.id shl 3 or wire.type.value) - when (wire.type) { - WireType.VARINT -> writeVarLong(wire.value as Long) - WireType.FIXED64, WireType.FIXED32 -> { - when (wire.value) { - is Int -> { - val bytes = ByteArray(4) - for (i in 0..3) { - bytes[i] = (wire.value shr (i * 8)).toByte() - } - stream.write(bytes) - } - is Long -> { - val bytes = ByteArray(8) - for (i in 0..7) { - bytes[i] = (wire.value shr (i * 8)).toByte() - } - stream.write(bytes) - } - is ByteArray -> stream.write(wire.value) - } - } - WireType.CHUNK -> { - val value = wire.value as ByteArray - writeVarInt(value.size) - stream.write(value) - } - WireType.START_GROUP -> { - val value = wire.value as ByteArray - stream.write(value) - } - WireType.END_GROUP -> return - } - } - - fun toByteArray(): ByteArray { - return stream.toByteArray() - } -}- \ No newline at end of file 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 @@ -1,14 +0,0 @@ -package me.rhunk.snapenhance.core.util.protobuf; - -enum class WireType(val value: Int) { - VARINT(0), - FIXED64(1), - CHUNK(2), - START_GROUP(3), - END_GROUP(4), - FIXED32(5); - - companion object { - fun fromValue(value: Int) = entries.firstOrNull { it.value == value } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/BitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/BitmojiSelfie.kt @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.core.util.snap - -object BitmojiSelfie { - 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? { - if (selfieId.isNullOrEmpty() || avatarId.isNullOrEmpty()) { - return null - } - return when (type) { - 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 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/MediaDownloaderHelper.kt @@ -1,45 +0,0 @@ -package me.rhunk.snapenhance.core.util.snap - -import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType -import me.rhunk.snapenhance.data.FileType -import java.io.BufferedInputStream -import java.io.InputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream - - -object MediaDownloaderHelper { - fun getFileType(bufferedInputStream: BufferedInputStream): FileType { - val buffer = ByteArray(16) - bufferedInputStream.mark(16) - bufferedInputStream.read(buffer) - bufferedInputStream.reset() - return FileType.fromByteArray(buffer) - } - - - fun getSplitElements( - inputStream: InputStream, - callback: (SplitMediaAssetType, InputStream) -> Unit - ) { - val bufferedInputStream = BufferedInputStream(inputStream) - val fileType = getFileType(bufferedInputStream) - - if (fileType != FileType.ZIP) { - callback(SplitMediaAssetType.ORIGINAL, bufferedInputStream) - return - } - - val zipInputStream = ZipInputStream(bufferedInputStream) - - var entry: ZipEntry? = zipInputStream.nextEntry - while (entry != null) { - if (entry.name.startsWith("overlay")) { - callback(SplitMediaAssetType.OVERLAY, zipInputStream) - } else if (entry.name.startsWith("media")) { - callback(SplitMediaAssetType.ORIGINAL, zipInputStream) - } - entry = zipInputStream.nextEntry - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/PreviewUtils.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/PreviewUtils.kt @@ -1,87 +0,0 @@ -package me.rhunk.snapenhance.core.util.snap - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Matrix -import android.media.MediaDataSource -import android.media.MediaMetadataRetriever -import me.rhunk.snapenhance.data.FileType -import java.io.File - -object PreviewUtils { - fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { - if (!isVideo) { - return BitmapFactory.decodeByteArray(data, 0, data.size) - } - return MediaMetadataRetriever().apply { - setDataSource(object : MediaDataSource() { - override fun readAt( - position: Long, - buffer: ByteArray, - offset: Int, - size: Int - ): Int { - var newSize = size - val length = data.size - if (position >= length) { - return -1 - } - if (position + newSize > length) { - newSize = length - position.toInt() - } - System.arraycopy(data, position.toInt(), buffer, offset, newSize) - return newSize - } - - override fun getSize(): Long { - return data.size.toLong() - } - override fun close() {} - }) - }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } - - fun createPreviewFromFile(file: File): Bitmap? { - return if (FileType.fromFile(file).isVideo) { - MediaMetadataRetriever().apply { - setDataSource(file.absolutePath) - }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } else { - BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options()) - } - } - - 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 mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { - val biggestBitmap = if (originalMedia.width * originalMedia.height > overlayLayer.width * overlayLayer.height) originalMedia else overlayLayer - val smallestBitmap = if (biggestBitmap == originalMedia) overlayLayer else originalMedia - - val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config) - - with(Canvas(mergedBitmap)) { - val scaleMatrix = Matrix().apply { - postScale(biggestBitmap.width.toFloat() / smallestBitmap.width.toFloat(), biggestBitmap.height.toFloat() / smallestBitmap.height.toFloat()) - } - - if (biggestBitmap == originalMedia) { - drawBitmap(originalMedia, 0f, 0f, null) - drawBitmap(overlayLayer, scaleMatrix, null) - } else { - drawBitmap(originalMedia, scaleMatrix, null) - drawBitmap(overlayLayer, 0f, 0f, null) - } - } - - return mergedBitmap - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/SnapWidgetBroadcastReceiverHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/snap/SnapWidgetBroadcastReceiverHelper.kt @@ -1,24 +0,0 @@ -package me.rhunk.snapenhance.core.util.snap - -import android.content.Intent -import me.rhunk.snapenhance.Constants - -object SnapWidgetBroadcastReceiverHelper { - private const val ACTION_WIDGET_UPDATE = "com.snap.android.WIDGET_APP_START_UPDATE_ACTION" - const val CLASS_NAME = "com.snap.widgets.core.BestFriendsWidgetProvider" - - fun create(targetAction: String, callback: Intent.() -> Unit): Intent { - with(Intent()) { - callback(this) - action = ACTION_WIDGET_UPDATE - putExtra(":)", true) - putExtra("action", targetAction) - setClassName(Constants.SNAPCHAT_PACKAGE_NAME, CLASS_NAME) - return this - } - } - - fun isIncomingIntentValid(intent: Intent): Boolean { - return intent.action == ACTION_WIDGET_UPDATE && intent.getBooleanExtra(":)", false) - } -}- \ No newline at end of file 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 @@ -0,0 +1,46 @@ +package me.rhunk.snapenhance.core.wrapper + +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.core.util.CallbackBuilder +import kotlin.reflect.KProperty + +abstract class AbstractWrapper( + protected var instance: Any? +) { + @Suppress("UNCHECKED_CAST") + inner class EnumAccessor<T>(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<*>) + } + + companion object { + fun newEmptyInstance(clazz: Class<*>): Any { + return CallbackBuilder.createEmptyObject(clazz.constructors[0]) ?: throw NullPointerException() + } + } + + fun instanceNonNull(): Any = instance!! + fun isPresent(): Boolean = instance != null + + override fun hashCode(): Int { + return instance.hashCode() + } + + override fun toString(): String { + return instance.toString() + } + + protected fun <T> enum(fieldName: String, defaultValue: T) = EnumAccessor(fieldName, defaultValue) + + fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T?): T? { + if (defaultValue == null) return null + val mContentType = XposedHelpers.getObjectField(instance, fieldName) as? Enum<*> ?: return null + return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) + } + + @Suppress("UNCHECKED_CAST") + fun setEnumValue(fieldName: String, value: Enum<*>) { + val type = instance!!.javaClass.declaredFields.find { it.name == fieldName }?.type as Class<out Enum<*>> + XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name)) + } +}+ \ No newline at end of file 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 @@ -0,0 +1,38 @@ +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 @@ -0,0 +1,14 @@ +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 + +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")) + 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 @@ -0,0 +1,13 @@ +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 + +class MessageContent(obj: Any?) : AbstractWrapper(obj) { + var content + get() = instanceNonNull().getObjectField("mContent") as ByteArray + set(value) = instanceNonNull().setObjectField("mContent", value) + 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 @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { + val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long + val conversationId: SnapUUID get() = SnapUUID(instanceNonNull().getObjectField("mConversationId")!!) +}+ \ 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 @@ -0,0 +1,15 @@ +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<Any> + set(value) = instanceNonNull().setObjectField("mStories", value) + var mPhoneNumbers get() = instanceNonNull().getObjectField("mPhoneNumbers") as ArrayList<Any> + set(value) = instanceNonNull().setObjectField("mPhoneNumbers", value) +}+ \ 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 @@ -0,0 +1,28 @@ +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 + +class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ + val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long + val readAt: Long get() = instanceNonNull().getObjectField("mReadAt") as Long + var playableSnapState by enum("mPlayableSnapState", PlayableSnapState.PLAYABLE) + + private fun getUUIDList(name: String): List<SnapUUID> { + return (instanceNonNull().getObjectField(name) as List<*>).map { SnapUUID(it!!) } + } + + val savedBy: List<SnapUUID> by lazy { + getUUIDList("mSavedBy") + } + val openedBy: List<SnapUUID> by lazy { + getUUIDList("mOpenedBy") + } + val seenBy: List<SnapUUID> by lazy { + getUUIDList("mSeenBy") + } + val reactions: List<UserIdToReaction> by lazy { + (instanceNonNull().getObjectField("mReactions") as List<*>).map { UserIdToReaction(it!!) } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ScSize.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/ScSize.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class ScSize( + obj: Any? +) : AbstractWrapper(obj) { + private val firstField by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == Int::class.javaPrimitiveType }.also { it.isAccessible = true } + } + + private val secondField by lazy { + instanceNonNull().javaClass.declaredFields.last { it.type == Int::class.javaPrimitiveType }.also { it.isAccessible = true } + } + + + var first: Int get() = firstField.getInt(instanceNonNull()) + set(value) { + firstField.setInt(instanceNonNull(), value) + } + + var second: Int get() = secondField.getInt(instanceNonNull()) + set(value) { + secondField.setInt(instanceNonNull(), value) + } +}+ \ 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 @@ -0,0 +1,44 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.SnapEnhance +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.nio.ByteBuffer +import java.util.UUID + +class SnapUUID(obj: Any?) : AbstractWrapper(obj) { + private val uuidString by lazy { toUUID().toString() } + + private val bytes: ByteArray get() = instanceNonNull().getObjectField("mId") as ByteArray + + private fun toUUID(): UUID { + val buffer = ByteBuffer.wrap(bytes) + return UUID(buffer.long, buffer.long) + } + + override fun toString(): String { + return uuidString + } + + fun toBytes() = bytes + + 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)) + } + fun fromBytes(bytes: ByteArray): SnapUUID { + val constructor = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java) + return SnapUUID(constructor.newInstance(bytes)) + } + fun fromUUID(uuid: UUID): SnapUUID { + val buffer = ByteBuffer.allocate(16) + buffer.putLong(uuid.mostSignificantBits) + buffer.putLong(uuid.leastSignificantBits) + return fromBytes(buffer.array()) + } + } +} 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 @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.wrapper.impl + +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { + val userId = SnapUUID(instanceNonNull().getObjectField("mUserId")) + val reactionId = (instanceNonNull().getObjectField("mReaction") + ?.getObjectField("mReactionContent") + ?.getObjectField("mIntentionType") as Long?) ?: 0 +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/EncryptionWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/EncryptionWrapper.kt @@ -0,0 +1,81 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media + +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.io.InputStream +import java.io.OutputStream +import java.lang.reflect.Field +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { + fun decrypt(data: ByteArray?): ByteArray { + return newCipher(Cipher.DECRYPT_MODE).doFinal(data) + } + + fun decrypt(inputStream: InputStream?): InputStream { + return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE)) + } + + fun decrypt(outputStream: OutputStream?): OutputStream { + return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE)) + } + + /** + * Search for a byte[] field with the specified length + * + * @param arrayLength the length of the byte[] field + * @return the field + */ + private fun searchByteArrayField(arrayLength: Int): Field { + return instanceNonNull()::class.java.fields.first { f -> + try { + if (!f.type.isArray || f.type + .componentType != Byte::class.javaPrimitiveType + ) return@first false + return@first (f.get(instanceNonNull()) as ByteArray).size == arrayLength + } catch (e: Exception) { + return@first false + } + } + } + + /** + * Create a new cipher with the specified mode + */ + fun newCipher(mode: Int): Cipher { + val cipher = cipher + cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec)) + return cipher + } + + /** + * Get the cipher from the encryption wrapper + */ + private val cipher: Cipher + get() = Cipher.getInstance("AES/CBC/PKCS5Padding") + + /** + * Get the key spec from the encryption wrapper + */ + val keySpec: ByteArray by lazy { + searchByteArrayField(32)[instance] as ByteArray + } + + /** + * Get the iv key parameter spec from the encryption wrapper + */ + val ivKeyParameterSpec: ByteArray by lazy { + searchByteArrayField(16)[instance] as ByteArray + } +} + + +@OptIn(ExperimentalEncodingApi::class) +fun EncryptionWrapper.toKeyPair() + = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec)) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/MediaInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/MediaInfo.kt @@ -0,0 +1,34 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media + +import android.os.Parcelable +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.lang.reflect.Field + + +class MediaInfo(obj: Any?) : AbstractWrapper(obj) { + val uri: String + get() { + val firstStringUriField = instanceNonNull().javaClass.fields.first { f: Field -> f.type == String::class.java } + return instanceNonNull().getObjectField(firstStringUriField.name) as String + } + + init { + instance?.let { + if (it is List<*>) { + if (it.size == 0) { + throw RuntimeException("MediaInfo is empty") + } + instance = it[0]!! + } + } + } + + val encryption: EncryptionWrapper? + get() { + val encryptionAlgorithmField = instanceNonNull().javaClass.fields.first { f: Field -> + f.type.isInterface && Parcelable::class.java.isAssignableFrom(f.type) + } + return encryptionAlgorithmField[instance]?.let { EncryptionWrapper(it) } + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt @@ -0,0 +1,11 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.dash + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class LongformVideoPlaylistItem(obj: Any?) : AbstractWrapper(obj) { + private val chapterList by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == List::class.java } + } + val chapters: List<SnapChapter> + get() = (chapterList.get(instanceNonNull()) as List<*>).map { SnapChapter(it) } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapChapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapChapter.kt @@ -0,0 +1,12 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.dash + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class SnapChapter (obj: Any?) : AbstractWrapper(obj) { + val snapId by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long + } + val startTimeMs by lazy { + instanceNonNull().javaClass.declaredFields.filter { it.type == Long::class.javaPrimitiveType }[1].get(instanceNonNull()) as Long + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/media/dash/SnapPlaylistItem.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.dash + +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper + +class SnapPlaylistItem (obj: Any?) : AbstractWrapper(obj) { + val snapId by lazy { + instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long + } +}+ \ No newline at end of file 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 @@ -0,0 +1,21 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.opera + +import me.rhunk.snapenhance.core.util.ReflectionHelper +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 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 @@ -0,0 +1,18 @@ +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 @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.core.wrapper.impl.media.opera + +import me.rhunk.snapenhance.core.util.ReflectionHelper +import me.rhunk.snapenhance.core.util.ktx.getObjectField +import me.rhunk.snapenhance.core.wrapper.AbstractWrapper +import java.lang.reflect.Field +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 + )!! + } + + val concurrentHashMap: ConcurrentHashMap<Any, Any> + get() = instanceNonNull().getObjectField(paramMapField.name) as ConcurrentHashMap<Any, Any> + + operator fun get(key: String): Any? { + return concurrentHashMap.keys.firstOrNull{ k: Any -> k.toString() == key }?.let { concurrentHashMap[it] } + } + + fun put(key: String, value: Any) { + val keyObject = concurrentHashMap.keys.firstOrNull { k: Any -> k.toString() == key } ?: key + concurrentHashMap[keyObject] = value + } + + fun containsKey(key: String): Boolean { + return concurrentHashMap.keys.any { k: Any -> k.toString() == key } + } + + override fun toString(): String { + return concurrentHashMap.toString() + } +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,75 +0,0 @@ -package me.rhunk.snapenhance.data - -import me.rhunk.snapenhance.core.Logger -import java.io.File -import java.io.InputStream - -enum class FileType( - val fileExtension: String? = null, - val mimeType: String, - val isVideo: Boolean = false, - val isImage: Boolean = false, - val isAudio: Boolean = false -) { - GIF("gif", "image/gif", false, false, false), - 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), - MPD("mpd", "text/xml", false, false, false), - UNKNOWN("dat", "application/octet-stream", false, false, false); - - companion object { - private val fileSignatures = mapOf( - "52494646" to WEBP, - "504b0304" to ZIP, - "89504e47" to PNG, - "00000020" to MP4, - "00000018" to MP4, - "0000001c" to MP4, - "494433" to MP3, - "4f676753" to OPUS, - "fff15" to AAC, - "ffd8ff" to JPG, - ) - - fun fromString(string: String?): FileType { - return entries.firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN - } - - private fun bytesToHex(bytes: ByteArray): String { - val result = StringBuilder() - for (b in bytes) { - result.append(String.format("%02x", b)) - } - return result.toString() - } - - fun fromFile(file: File): FileType { - file.inputStream().use { inputStream -> - val buffer = ByteArray(16) - inputStream.read(buffer) - return fromByteArray(buffer) - } - } - - fun fromByteArray(array: ByteArray): 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.also { - Logger.directDebug("unknown file type, header: $hex", "FileType") - } - } - - fun fromInputStream(inputStream: InputStream): FileType { - val buffer = ByteArray(16) - inputStream.read(buffer) - return fromByteArray(buffer) - } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/LocalePair.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/LocalePair.kt @@ -1,3 +0,0 @@ -package me.rhunk.snapenhance.data - -data class LocalePair(val locale: String, val content: String)- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt @@ -1,178 +0,0 @@ -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 - -class MessageSender( - private val context: ModContext, -) { - companion object { - val redSnapProto: (ByteArray?) -> ByteArray = { extras -> - ProtoWriter().apply { - from(11) { - from(5) { - from(1) { - 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()) - } - } - extras?.let { - addBuffer(13, it) - } - } - }.toByteArray() - } - - val audioNoteProto: (Long) -> ByteArray = { duration -> - ProtoWriter().apply { - from(6, 1) { - from(1) { - addVarInt(2, 4) - from(5) { - addVarInt(1, 0) - addVarInt(2, 0) - } - addVarInt(7, 0) - addVarInt(13, duration) - } - } - }.toByteArray() - } - - } - - private val sendMessageCallback by lazy { context.mappings.getMappedClass("callbacks", "SendMessageCallback") } - - private val platformAnalyticsCreatorClass by lazy { - context.mappings.getMappedClass("PlatformAnalyticsCreator") - } - - private fun defaultPlatformAnalytics(): ByteArray { - val analyticsSource = platformAnalyticsCreatorClass.constructors[0].parameterTypes[0] - val chatAnalyticsSource = analyticsSource.enumConstants.first { it.toString() == "CHAT" } - - val platformAnalyticsDefaultArgs = arrayOf(chatAnalyticsSource, null, null, null, null, null, null, null, null, null, 0L, 0L, - null, null, false, null, null, 0L, null, null, false, null, null, - null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, false, null, null, false, 0L, -2, 8191) - - val platformAnalyticsInstance = platformAnalyticsCreatorClass.constructors[0].newInstance( - *platformAnalyticsDefaultArgs - ) ?: throw Exception("Failed to create platform analytics instance") - - return platformAnalyticsInstance.javaClass.declaredMethods.first { it.returnType == ByteArray::class.java } - .invoke(platformAnalyticsInstance) as ByteArray? - ?: throw Exception("Failed to get platform analytics content") - } - - private fun createLocalMessageContentTemplate( - contentType: ContentType, - messageContent: ByteArray, - localMediaReference: ByteArray? = null, - metricMessageMediaType: MetricsMessageMediaType = MetricsMessageMediaType.DERIVED_FROM_MESSAGE_TYPE, - metricsMediaType: MetricsMessageType = MetricsMessageType.TEXT, - savePolicy: String = "PROHIBITED", - ): String { - return """ - { - "mAllowsTranscription": false, - "mBotMention": false, - "mContent": [${messageContent.joinToString(",")}], - "mContentType": "${contentType.name}", - "mIncidentalAttachments": [], - "mLocalMediaReferences": [${ - if (localMediaReference != null) { - "{\"mId\": [${localMediaReference.joinToString(",")}]}" - } else { - "" - } - }], - "mPlatformAnalytics": { - "mAttemptId": { - "mId": [${(1..16).map { (-127 ..127).random() }.joinToString(",")}] - }, - "mContent": [${defaultPlatformAnalytics().joinToString(",")}], - "mMetricsMessageMediaType": "${metricMessageMediaType.name}", - "mMetricsMessageType": "${metricsMediaType.name}", - "mReactionSource": "NONE" - }, - "mSavePolicy": "$savePolicy" - } - """.trimIndent() - } - - private fun internalSendMessage(conversations: List<SnapUUID>, localMessageContentTemplate: String, callback: Any) { - val sendMessageWithContentMethod = context.classCache.conversationManager.declaredMethods.first { it.name == "sendMessageWithContent" } - - 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() - } - - sendMessageWithContentMethod.invoke(context.feature(Messaging::class).conversationManager, messageDestinations.instanceNonNull(), localMessageContent, callback) - } - - //TODO: implement sendSnapMessage - /* - fun sendSnapMessage(conversations: List<SnapUUID>, chatMediaType: ChatMediaType, uri: Uri, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { - val mediaReferenceBuffer = FlatBufferBuilder(0).apply { - val uriOffset = createString(uri.toString()) - forceDefaults(true) - startTable(2) - addOffset(1, uriOffset, 0) - addInt(0, chatMediaType.value, 0) - finish(endTable()) - finished() - }.sizedByteArray() - - internalSendMessage(conversations, createLocalMessageContentTemplate( - contentType = ContentType.SNAP, - messageContent = redSnapProto(chatMediaType == ChatMediaType.AUDIO || chatMediaType == ChatMediaType.VIDEO), - localMediaReference = mediaReferenceBuffer, - metricMessageMediaType = MetricsMessageMediaType.IMAGE, - metricsMediaType = MetricsMessageType.SNAP - ), CallbackBuilder(sendMessageCallback) - .override("onSuccess") { - onSuccess() - } - .override("onError") { - onError(it.arg(0)) - } - .build()) - }*/ - - fun sendChatMessage(conversations: List<SnapUUID>, message: String, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { - internalSendMessage(conversations, createLocalMessageContentTemplate(ContentType.CHAT, ProtoWriter().apply { - from(2) { - addString(1, message) - } - }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) - .override("onSuccess", callback = { onSuccess() }) - .override("onError", callback = { onError(it.arg(0)) }) - .build()) - } - - fun sendCustomChatMessage(conversations: List<SnapUUID>, contentType: ContentType, message: ProtoWriter.() -> Unit, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) { - internalSendMessage(conversations, createLocalMessageContentTemplate(contentType, ProtoWriter().apply { - message() - }.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback) - .override("onSuccess", callback = { onSuccess() }) - .override("onError", callback = { onError(it.arg(0)) }) - .build()) - } -}- \ 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 @@ -1,30 +0,0 @@ -package me.rhunk.snapenhance.data - -class SnapClassCache ( - private val classLoader: ClassLoader -) { - val snapUUID by lazy { findClass("com.snapchat.client.messaging.UUID") } - val snapManager by lazy { findClass("com.snapchat.client.messaging.SnapManager\$CppProxy") } - val conversationManager by lazy { findClass("com.snapchat.client.messaging.ConversationManager\$CppProxy") } - 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 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") } - val localMessageContent by lazy { findClass("com.snapchat.client.messaging.LocalMessageContent") } - 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")} - 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 { - classLoader.loadClass(className) - } catch (e: ClassNotFoundException) { - throw RuntimeException("Failed to find class $className", e) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt @@ -1,172 +0,0 @@ -package me.rhunk.snapenhance.data - -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader - -enum class MessageState { - PREPARING, SENDING, COMMITTED, FAILED, CANCELING -} - -enum class NotificationType ( - val key: String, - val isIncoming: Boolean = false, - val associatedOutgoingContentType: ContentType? = null, -) { - SCREENSHOT("chat_screenshot", true, ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT), - SCREEN_RECORD("chat_screen_record", true, ContentType.STATUS_CONVERSATION_CAPTURE_RECORD), - CAMERA_ROLL_SAVE("camera_roll_save", true, ContentType.STATUS_SAVE_TO_CAMERA_ROLL), - SNAP_REPLAY("snap_replay", true, ContentType.STATUS), - SNAP("snap",true), - CHAT("chat",true), - CHAT_REPLY("chat_reply",true), - TYPING("typing", true), - STORIES("stories",true), - INITIATE_AUDIO("initiate_audio",true), - ABANDON_AUDIO("abandon_audio", false, ContentType.STATUS_CALL_MISSED_AUDIO), - INITIATE_VIDEO("initiate_video",true), - ABANDON_VIDEO("abandon_video", false, ContentType.STATUS_CALL_MISSED_VIDEO); - - companion object { - fun getIncomingValues(): List<NotificationType> { - return entries.filter { it.isIncoming }.toList() - } - - fun getOutgoingValues(): List<NotificationType> { - return entries.filter { it.associatedOutgoingContentType != null }.toList() - } - - fun fromContentType(contentType: ContentType): NotificationType? { - return entries.firstOrNull { it.associatedOutgoingContentType == contentType } - } - } -} - -enum class ContentType(val id: Int) { - UNKNOWN(-1), - SNAP(0), - CHAT(1), - EXTERNAL_MEDIA(2), - SHARE(3), - NOTE(4), - STICKER(5), - STATUS(6), - LOCATION(7), - STATUS_SAVE_TO_CAMERA_ROLL(8), - STATUS_CONVERSATION_CAPTURE_SCREENSHOT(9), - STATUS_CONVERSATION_CAPTURE_RECORD(10), - STATUS_CALL_MISSED_VIDEO(11), - STATUS_CALL_MISSED_AUDIO(12), - LIVE_LOCATION_SHARE(13), - CREATIVE_TOOL_ITEM(14), - FAMILY_CENTER_INVITE(15), - FAMILY_CENTER_ACCEPT(16), - FAMILY_CENTER_LEAVE(17), - STATUS_PLUS_GIFT(18); - - companion object { - fun fromId(i: Int): ContentType { - return entries.firstOrNull { it.id == i } ?: UNKNOWN - } - - fun fromMessageContainer(protoReader: ProtoReader?): ContentType? { - if (protoReader == null) return null - return protoReader.run { - when { - contains(8) -> STATUS - contains(2) -> CHAT - contains(11) -> SNAP - contains(6) -> NOTE - contains(3) -> EXTERNAL_MEDIA - contains(4) -> STICKER - contains(5) -> SHARE - contains(7) -> EXTERNAL_MEDIA // story replies - else -> null - } - } - } - } -} - -enum class PlayableSnapState { - NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE -} - -enum class MetricsMessageMediaType { - NO_MEDIA, - IMAGE, - VIDEO, - VIDEO_NO_SOUND, - GIF, - DERIVED_FROM_MESSAGE_TYPE, - REACTION -} - -enum class MetricsMessageType { - TEXT, - STICKER, - CUSTOM_STICKER, - SNAP, - AUDIO_NOTE, - MEDIA, - BATCHED_MEDIA, - MISSED_AUDIO_CALL, - MISSED_VIDEO_CALL, - JOINED_CALL, - LEFT_CALL, - SNAPCHATTER, - LOCATION_SHARE, - LOCATION_REQUEST, - SCREENSHOT, - SCREEN_RECORDING, - GAME_CLOSED, - STORY_SHARE, - MAP_DROP_SHARE, - MAP_STORY_SHARE, - MAP_STORY_SNAP_SHARE, - MAP_HEAT_SNAP_SHARE, - MAP_SCREENSHOT_SHARE, - MEMORIES_STORY, - SEARCH_STORY_SHARE, - SEARCH_STORY_SNAP_SHARE, - DISCOVER_SHARE, - SHAZAM_SHARE, - SAVE_TO_CAMERA_ROLL, - GAME_SCORE_SHARE, - SNAP_PRO_PROFILE_SHARE, - SNAP_PRO_SNAP_SHARE, - CANVAS_APP_SHARE, - AD_SHARE, - STORY_REPLY, - SPOTLIGHT_STORY_SHARE, - CAMEO, - MEMOJI, - BITMOJI_OUTFIT_SHARE, - LIVE_LOCATION_SHARE, - CREATIVE_TOOL_ITEM, - SNAP_KIT_INVITE_SHARE, - QUOTE_REPLY_SHARE, - BLOOPS_STORY_SHARE, - SNAP_PRO_SAVED_STORY_SHARE, - PLACE_PROFILE_SHARE, - PLACE_STORY_SHARE, - SAVED_STORY_SHARE -} -enum class MediaReferenceType { - UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO -} - -enum class FriendLinkType(val value: Int, val shortName: String) { - MUTUAL(0, "mutual"), - OUTGOING(1, "outgoing"), - BLOCKED(2, "blocked"), - DELETED(3, "deleted"), - FOLLOWING(4, "following"), - SUGGESTED(5, "suggested"), - INCOMING(6, "incoming"), - INCOMING_FOLLOWER(7, "incoming_follower"); - - companion object { - fun fromValue(value: Int): FriendLinkType { - return entries.firstOrNull { it.value == value } ?: MUTUAL - } - } -}- \ 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 @@ -1,46 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper - -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.core.util.CallbackBuilder -import kotlin.reflect.KProperty - -abstract class AbstractWrapper( - protected var instance: Any? -) { - @Suppress("UNCHECKED_CAST") - inner class EnumAccessor<T>(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<*>) - } - - companion object { - fun newEmptyInstance(clazz: Class<*>): Any { - return CallbackBuilder.createEmptyObject(clazz.constructors[0]) ?: throw NullPointerException() - } - } - - fun instanceNonNull(): Any = instance!! - fun isPresent(): Boolean = instance != null - - override fun hashCode(): Int { - return instance.hashCode() - } - - override fun toString(): String { - return instance.toString() - } - - protected fun <T> enum(fieldName: String, defaultValue: T) = EnumAccessor(fieldName, defaultValue) - - fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T?): T? { - if (defaultValue == null) return null - val mContentType = XposedHelpers.getObjectField(instance, fieldName) as? Enum<*> ?: return null - return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) - } - - @Suppress("UNCHECKED_CAST") - fun setEnumValue(fieldName: String, value: Enum<*>) { - val type = instance!!.javaClass.declaredFields.find { it.name == fieldName }?.type as Class<out Enum<*>> - XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name)) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/FriendActionButton.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/FriendActionButton.kt @@ -1,38 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.View -import me.rhunk.snapenhance.SnapEnhance -import me.rhunk.snapenhance.data.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/data/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt @@ -1,14 +0,0 @@ -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 - -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")) - var messageState by enum("mState", MessageState.COMMITTED) -}- \ No newline at end of file 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 @@ -1,13 +0,0 @@ -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 - -class MessageContent(obj: Any?) : AbstractWrapper(obj) { - var content - get() = instanceNonNull().getObjectField("mContent") as ByteArray - set(value) = instanceNonNull().setObjectField("mContent", value) - var contentType by enum("mContentType", ContentType.UNKNOWN) -}- \ No newline at end of file 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 @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { - val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long - val conversationId: SnapUUID get() = SnapUUID(instanceNonNull().getObjectField("mConversationId")!!) -}- \ No newline at end of file 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 @@ -1,15 +0,0 @@ -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 - -@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<Any> - set(value) = instanceNonNull().setObjectField("mStories", value) - var mPhoneNumbers get() = instanceNonNull().getObjectField("mPhoneNumbers") as ArrayList<Any> - set(value) = instanceNonNull().setObjectField("mPhoneNumbers", value) -}- \ No newline at end of file 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 @@ -1,28 +0,0 @@ -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 - -class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ - val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long - val readAt: Long get() = instanceNonNull().getObjectField("mReadAt") as Long - var playableSnapState by enum("mPlayableSnapState", PlayableSnapState.PLAYABLE) - - private fun getUUIDList(name: String): List<SnapUUID> { - return (instanceNonNull().getObjectField(name) as List<*>).map { SnapUUID(it!!) } - } - - val savedBy: List<SnapUUID> by lazy { - getUUIDList("mSavedBy") - } - val openedBy: List<SnapUUID> by lazy { - getUUIDList("mOpenedBy") - } - val seenBy: List<SnapUUID> by lazy { - getUUIDList("mSeenBy") - } - val reactions: List<UserIdToReaction> by lazy { - (instanceNonNull().getObjectField("mReactions") as List<*>).map { UserIdToReaction(it!!) } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/ScSize.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/ScSize.kt @@ -1,26 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class ScSize( - obj: Any? -) : AbstractWrapper(obj) { - private val firstField by lazy { - instanceNonNull().javaClass.declaredFields.first { it.type == Int::class.javaPrimitiveType }.also { it.isAccessible = true } - } - - private val secondField by lazy { - instanceNonNull().javaClass.declaredFields.last { it.type == Int::class.javaPrimitiveType }.also { it.isAccessible = true } - } - - - var first: Int get() = firstField.getInt(instanceNonNull()) - set(value) { - firstField.setInt(instanceNonNull(), value) - } - - var second: Int get() = secondField.getInt(instanceNonNull()) - set(value) { - secondField.setInt(instanceNonNull(), value) - } -}- \ No newline at end of file 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 @@ -1,44 +0,0 @@ -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 java.nio.ByteBuffer -import java.util.UUID - -class SnapUUID(obj: Any?) : AbstractWrapper(obj) { - private val uuidString by lazy { toUUID().toString() } - - private val bytes: ByteArray get() = instanceNonNull().getObjectField("mId") as ByteArray - - private fun toUUID(): UUID { - val buffer = ByteBuffer.wrap(bytes) - return UUID(buffer.long, buffer.long) - } - - override fun toString(): String { - return uuidString - } - - fun toBytes() = bytes - - 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)) - } - fun fromBytes(bytes: ByteArray): SnapUUID { - val constructor = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java) - return SnapUUID(constructor.newInstance(bytes)) - } - fun fromUUID(uuid: UUID): SnapUUID { - val buffer = ByteBuffer.allocate(16) - buffer.putLong(uuid.mostSignificantBits) - buffer.putLong(uuid.leastSignificantBits) - return fromBytes(buffer.array()) - } - } -} 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 @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl - -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { - val userId = SnapUUID(instanceNonNull().getObjectField("mUserId")) - val reactionId = (instanceNonNull().getObjectField("mReaction") - ?.getObjectField("mReactionContent") - ?.getObjectField("mIntentionType") as Long?) ?: 0 -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt @@ -1,73 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper -import java.io.InputStream -import java.io.OutputStream -import java.lang.reflect.Field -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.CipherOutputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { - fun decrypt(data: ByteArray?): ByteArray { - return newCipher(Cipher.DECRYPT_MODE).doFinal(data) - } - - fun decrypt(inputStream: InputStream?): InputStream { - return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE)) - } - - fun decrypt(outputStream: OutputStream?): OutputStream { - return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE)) - } - - /** - * Search for a byte[] field with the specified length - * - * @param arrayLength the length of the byte[] field - * @return the field - */ - private fun searchByteArrayField(arrayLength: Int): Field { - return instanceNonNull()::class.java.fields.first { f -> - try { - if (!f.type.isArray || f.type - .componentType != Byte::class.javaPrimitiveType - ) return@first false - return@first (f.get(instanceNonNull()) as ByteArray).size == arrayLength - } catch (e: Exception) { - return@first false - } - } - } - - /** - * Create a new cipher with the specified mode - */ - fun newCipher(mode: Int): Cipher { - val cipher = cipher - cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec)) - return cipher - } - - /** - * Get the cipher from the encryption wrapper - */ - private val cipher: Cipher - get() = Cipher.getInstance("AES/CBC/PKCS5Padding") - - /** - * Get the key spec from the encryption wrapper - */ - val keySpec: ByteArray by lazy { - searchByteArrayField(32)[instance] as ByteArray - } - - /** - * Get the iv key parameter spec from the encryption wrapper - */ - val ivKeyParameterSpec: ByteArray by lazy { - searchByteArrayField(16)[instance] as ByteArray - } -} 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 @@ -1,34 +0,0 @@ -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 java.lang.reflect.Field - - -class MediaInfo(obj: Any?) : AbstractWrapper(obj) { - val uri: String - get() { - val firstStringUriField = instanceNonNull().javaClass.fields.first { f: Field -> f.type == String::class.java } - return instanceNonNull().getObjectField(firstStringUriField.name) as String - } - - init { - instance?.let { - if (it is List<*>) { - if (it.size == 0) { - throw RuntimeException("MediaInfo is empty") - } - instance = it[0]!! - } - } - } - - val encryption: EncryptionWrapper? - get() { - val encryptionAlgorithmField = instanceNonNull().javaClass.fields.first { f: Field -> - f.type.isInterface && Parcelable::class.java.isAssignableFrom(f.type) - } - return encryptionAlgorithmField[instance]?.let { EncryptionWrapper(it) } - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/LongformVideoPlaylistItem.kt @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class LongformVideoPlaylistItem(obj: Any?) : AbstractWrapper(obj) { - private val chapterList by lazy { - instanceNonNull().javaClass.declaredFields.first { it.type == List::class.java } - } - val chapters: List<SnapChapter> - get() = (chapterList.get(instanceNonNull()) as List<*>).map { SnapChapter(it) } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapChapter.kt @@ -1,12 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class SnapChapter (obj: Any?) : AbstractWrapper(obj) { - val snapId by lazy { - instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long - } - val startTimeMs by lazy { - instanceNonNull().javaClass.declaredFields.filter { it.type == Long::class.javaPrimitiveType }[1].get(instanceNonNull()) as Long - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/dash/SnapPlaylistItem.kt @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.dash - -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class SnapPlaylistItem (obj: Any?) : AbstractWrapper(obj) { - val snapId by lazy { - instanceNonNull().javaClass.declaredFields.first { it.type == Long::class.javaPrimitiveType }.get(instanceNonNull()) as Long - } -}- \ No newline at end of file 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 @@ -1,21 +0,0 @@ -package me.rhunk.snapenhance.data.wrapper.impl.media.opera - -import me.rhunk.snapenhance.core.util.ReflectionHelper -import me.rhunk.snapenhance.data.wrapper.AbstractWrapper - -class Layer(obj: Any?) : AbstractWrapper(obj) { - val paramMap: ParamMap - get() { - val layerControllerField = ReflectionHelper.searchFieldContainsToString( - instanceNonNull()::class.java, - instance, - "OperaPageModel" - )!! - - 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/data/wrapper/impl/media/opera/LayerController.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt @@ -1,18 +0,0 @@ -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 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/data/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt @@ -1,37 +0,0 @@ -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 java.lang.reflect.Field -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 - )!! - } - - val concurrentHashMap: ConcurrentHashMap<Any, Any> - get() = instanceNonNull().getObjectField(paramMapField.name) as ConcurrentHashMap<Any, Any> - - operator fun get(key: String): Any? { - return concurrentHashMap.keys.firstOrNull{ k: Any -> k.toString() == key }?.let { concurrentHashMap[it] } - } - - fun put(key: String, value: Any) { - val keyObject = concurrentHashMap.keys.firstOrNull { k: Any -> k.toString() == key } ?: key - concurrentHashMap[keyObject] = value - } - - fun containsKey(key: String): Boolean { - return concurrentHashMap.keys.any { k: Any -> k.toString() == key } - } - - override fun toString(): String { - return concurrentHashMap.toString() - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/BridgeFileFeature.kt @@ -1,54 +0,0 @@ -package me.rhunk.snapenhance.features - -import me.rhunk.snapenhance.core.bridge.types.BridgeFileType -import java.io.BufferedReader -import java.io.ByteArrayInputStream -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets - -abstract class BridgeFileFeature(name: String, private val bridgeFileType: BridgeFileType, loadParams: Int) : Feature(name, loadParams) { - private val fileLines = mutableListOf<String>() - - protected fun readFile() { - val temporaryLines = mutableListOf<String>() - val fileData: ByteArray = context.bridgeClient.createAndReadFile(bridgeFileType, ByteArray(0)) - with(BufferedReader(InputStreamReader(ByteArrayInputStream(fileData), StandardCharsets.UTF_8))) { - var line = "" - while (readLine()?.also { line = it } != null) temporaryLines.add(line) - close() - } - fileLines.clear() - fileLines.addAll(temporaryLines) - } - - private fun updateFile() { - val sb = StringBuilder() - fileLines.forEach { - sb.append(it).append("\n") - } - context.bridgeClient.writeFile(bridgeFileType, sb.toString().toByteArray(Charsets.UTF_8)) - } - - protected fun exists(line: String) = fileLines.contains(line) - - protected fun toggle(line: String) { - if (exists(line)) fileLines.remove(line) else fileLines.add(line) - updateFile() - } - - protected fun setState(line: String, state: Boolean) { - if (state) { - if (!exists(line)) fileLines.add(line) - } else { - if (exists(line)) fileLines.remove(line) - } - updateFile() - } - - protected fun reload() = readFile() - - protected fun put(line: String) { - fileLines.add(line) - updateFile() - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt @@ -1,39 +0,0 @@ -package me.rhunk.snapenhance.features - -import me.rhunk.snapenhance.ModContext - -abstract class Feature( - val featureKey: String, - val loadParams: Int = FeatureLoadParams.INIT_SYNC -) { - lateinit var context: ModContext - - /** - * called on the main thread when the mod initialize - */ - open fun init() {} - - /** - * called on a dedicated thread when the mod initialize - */ - open fun asyncInit() {} - - /** - * called when the Snapchat Activity is created - */ - open fun onActivityCreate() {} - - - /** - * called on a dedicated thread when the Snapchat Activity is created - */ - open fun asyncOnActivityCreate() {} - - protected fun findClass(name: String): Class<*> { - return context.androidContext.classLoader.loadClass(name) - } - - 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/FeatureLoadParams.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.features - -object FeatureLoadParams { - const val NO_INIT = 0 - - const val INIT_SYNC = 0b0001 - const val ACTIVITY_CREATE_SYNC = 0b0010 - - const val INIT_ASYNC = 0b0100 - const val ACTIVITY_CREATE_ASYNC = 0b1000 -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/MessagingRuleFeature.kt @@ -1,30 +0,0 @@ -package me.rhunk.snapenhance.features - -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) { - - open fun getRuleState() = context.config.rules.getRuleState(ruleType) - - fun setState(conversationId: String, state: Boolean) { - context.bridgeClient.setRule( - context.database.getDMOtherParticipant(conversationId) ?: conversationId, - ruleType, - state - ) - } - - fun getState(conversationId: String) = - context.bridgeClient.getRules( - context.database.getDMOtherParticipant(conversationId) ?: conversationId - ).contains(ruleType) && getRuleState() != null - - fun canUseRule(conversationId: String): Boolean { - val state = getState(conversationId) - if (context.config.rules.getRuleState(ruleType) == RuleState.BLACKLIST) { - return !state - } - return state - } -}- \ No newline at end of file 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 @@ -1,81 +0,0 @@ -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 - -class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) { - override fun init() { - val propertyOverrides = mutableMapOf<String, Pair<(() -> Boolean), Any>>() - - fun overrideProperty(key: String, filter: () -> Boolean, value: Any) { - propertyOverrides[key] = Pair(filter, value) - } - - overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.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) - - 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("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") - } - - val compositeConfigurationProviderMappings = context.mappings.getMappedMap("CompositeConfigurationProvider") - val enumMappings = compositeConfigurationProviderMappings["enum"] as Map<*, *> - - findClass(compositeConfigurationProviderMappings["class"].toString()).hook( - compositeConfigurationProviderMappings["observeProperty"].toString(), - HookStage.BEFORE - ) { param -> - val enumData = param.arg<Any>(0) - val key = enumData.toString() - val setValue: (Any?) -> Unit = { value -> - val valueHolder = XposedHelpers.callMethod(enumData, enumMappings["getValue"].toString()) - valueHolder.setObjectField(enumMappings["defaultValueField"].toString(), value) - } - - propertyOverrides[key]?.let { (filter, value) -> - if (!filter()) return@let - setValue(value) - } - } - - findClass(compositeConfigurationProviderMappings["class"].toString()).hook( - compositeConfigurationProviderMappings["getProperty"].toString(), - HookStage.AFTER - ) { param -> - val propertyKey = param.arg<Any>(0).toString() - - propertyOverrides[propertyKey]?.let { (filter, value) -> - if (!filter()) return@let - param.setResult(value) - } - } - - arrayOf("getBoolean", "getInt", "getLong", "getFloat", "getString").forEach { methodName -> - findClass("android.app.SharedPreferencesImpl").hook( - methodName, - HookStage.BEFORE - ) { param -> - val key = param.argNullable<Any>(0).toString() - propertyOverrides[key]?.let { (filter, value) -> - if (!filter()) return@let - param.setResult(value) - } - } - } - } -}- \ No newline at end of file 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 @@ -1,94 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import me.rhunk.snapenhance.core.event.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 -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 - -class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { - lateinit var conversationManager: Any - - var openedConversationUUID: SnapUUID? = null - var lastFetchConversationUserUUID: SnapUUID? = null - var lastFetchConversationUUID: SnapUUID? = null - var lastFetchGroupConversationUUID: SnapUUID? = null - var lastFocusedMessageId: Long = -1 - - override fun init() { - Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { - conversationManager = it.thisObject() - } - } - - override fun onActivityCreate() { - context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings -> - findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param -> - val viewItemContainer = param.arg<Any>(0) - val viewItem = viewItemContainer.getObjectField(mappings["viewModelField"].toString()).toString() - val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also { - if (it.startsWith("null")) return@hook - } - context.database.getConversationType(conversationId)?.takeIf { it == 1 }?.run { - lastFetchGroupConversationUUID = SnapUUID.fromString(conversationId) - } - } - } - - context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param -> - val userIdToConversation = (param.arg<ArrayList<*>>(0)) - .takeIf { it.isNotEmpty() } - ?.get(0) ?: return@hook - - lastFetchConversationUUID = SnapUUID(userIdToConversation.getObjectField("mConversationId")) - lastFetchConversationUserUUID = SnapUUID(userIdToConversation.getObjectField("mUserId")) - } - - with(context.classCache.conversationManager) { - Hooker.hook(this, "enterConversation", HookStage.BEFORE) { - openedConversationUUID = SnapUUID(it.arg(0)) - } - - Hooker.hook(this, "exitConversation", HookStage.BEFORE) { - openedConversationUUID = null - } - } - - } - - 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()) - }) { - it.setResult(null) - } - } - - //get last opened snap for media downloader - context.event.subscribe(OnSnapInteractionEvent::class) { event -> - openedConversationUUID = event.conversationId - lastFocusedMessageId = event.messageId - } - - Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param -> - lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any)) - lastFocusedMessageId = param.arg(1) - } - - Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE, { - hideTypingNotification || stealthMode.canUseRule(openedConversationUUID.toString()) - }) { - it.setResult(null) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ScopeSync.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ScopeSync.kt @@ -1,43 +0,0 @@ -package me.rhunk.snapenhance.features.impl - -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.messaging.SocialScope -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams - -class ScopeSync : Feature("Scope Sync", loadParams = FeatureLoadParams.INIT_SYNC) { - companion object { - private const val DELAY_BEFORE_SYNC = 2000L - } - - private val updateJobs = mutableMapOf<String, Job>() - - private fun sync(conversationId: String) { - context.database.getDMOtherParticipant(conversationId)?.also { participant -> - context.bridgeClient.triggerSync(SocialScope.FRIEND, participant) - } ?: run { - context.bridgeClient.triggerSync(SocialScope.GROUP, conversationId) - } - } - - override fun init() { - context.event.subscribe(SendMessageWithContentEvent::class) { event -> - if (event.messageContent.contentType != ContentType.SNAP) return@subscribe - - event.addCallbackResult("onSuccess") { - event.destinations.conversations.map { it.toString() }.forEach { conversationId -> - updateJobs[conversationId]?.also { it.cancel() } - - updateJobs[conversationId] = (context.coroutineScope.launch { - delay(DELAY_BEFORE_SYNC) - sync(conversationId) - }) - } - } - } - } -}- \ 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 @@ -1,714 +0,0 @@ -package me.rhunk.snapenhance.features.impl.downloader - -import android.annotation.SuppressLint -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.text.InputType -import android.view.Gravity -import android.view.ViewGroup.MarginLayoutParams -import android.widget.EditText -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.SnapEnhance -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.* -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.MediaDownloaderHelper -import me.rhunk.snapenhance.core.util.snap.PreviewUtils -import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo -import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem -import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem -import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer -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 -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import java.io.ByteArrayInputStream -import java.nio.file.Paths -import java.text.SimpleDateFormat -import java.util.Locale -import kotlin.coroutines.suspendCoroutine -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -private fun String.sanitizeForPath(): String { - return this.replace(" ", "_") - .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<SplitMediaAssetType, MediaInfo>? = null - private var lastSeenMapParams: ParamMap? = null - - private val translations by lazy { - context.translation.getCategory("download_processor") - } - - private fun provideDownloadManagerClient( - mediaIdentifier: String, - mediaAuthor: String, - downloadSource: MediaDownloadSource, - 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 - if (downloadLogging.contains("started")) { - context.shortToast(translations["download_started_toast"]) - } - - val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor) - - return DownloadManagerClient( - context = context, - metadata = DownloadMetadata( - mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { - generatedHash - } else null, - mediaAuthor = mediaAuthor, - downloadSource = downloadSource.key, - iconUrl = iconUrl, - outputPath = outputPath - ), - callback = object: DownloadCallback.Stub() { - override fun onSuccess(outputFile: String) { - if (!downloadLogging.contains("success")) return - context.log.verbose("onSuccess: outputFile=$outputFile") - context.shortToast(translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/"))) - } - - override fun onProgress(message: String) { - if (!downloadLogging.contains("progress")) return - context.log.verbose("onProgress: message=$message") - context.shortToast(message) - } - - override fun onFailure(message: String, throwable: String?) { - if (!downloadLogging.contains("failure")) return - context.log.verbose("onFailure: message=$message, throwable=$throwable") - throwable?.let { - context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty())) - return - } - context.shortToast(message) - } - } - ) - } - - - private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): 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 finalPath = StringBuilder() - - fun appendFileName(string: String) { - if (finalPath.isEmpty() || finalPath.endsWith("/")) { - finalPath.append(string) - } else { - finalPath.append("_").append(string) - } - } - - 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) - } - if (pathFormat.contains("append_source")) { - appendFileName(downloadSource.pathName) - } - if (pathFormat.contains("append_username")) { - appendFileName(sanitizedMediaAuthor) - } - if (pathFormat.contains("append_date_time")) { - appendFileName(currentDateTime) - } - - if (finalPath.isEmpty()) finalPath.append(hexHash) - - return finalPath.toString() - } - - /* - * Download the last seen media - */ - fun downloadLastOperaMediaAsync() { - if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - context.executeAsync { - handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true) - } - } - - fun showLastOperaDebugMediaInfo() { - if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - - context.runOnUiThread { - val mediaInfoText = lastSeenMapParams?.concurrentHashMap?.map { (key, value) -> - val transformedValue = value.let { - if (it::class.java == SnapEnhance.classCache.snapUUID) { - SnapUUID(it).toString() - } - it - } - "- $key: $transformedValue" - }?.joinToString("\n") ?: "No media info found" - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { - setTitle("Debug Media Info") - setView(EditText(context).apply { - inputType = InputType.TYPE_NULL - setTextIsSelectable(true) - isSingleLine = false - textSize = 12f - setPadding(20, 20, 20, 20) - setText(mediaInfoText) - setTextColor(context.resources.getColor(android.R.color.white, context.theme)) - }) - setNeutralButton("Copy") { _, _ -> - this@MediaDownloader.context.copyToClipboard(mediaInfoText) - } - setPositiveButton("Download") { _, _ -> - downloadLastOperaMediaAsync() - } - setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } - }.show() - } - } - - private fun handleLocalReferences(path: String) = runBlocking { - Uri.parse(path).let { uri -> - if (uri.scheme == "file") { - return@let suspendCoroutine<String> { continuation -> - context.httpServer.ensureServerStarted { - val file = Paths.get(uri.path).toFile() - val url = putDownloadableContent(file.inputStream(), file.length()) - continuation.resumeWith(Result.success(url)) - } - } - } - path - } - } - - private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) { - if (mediaInfoMap.isEmpty()) return - - val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! - val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) - - mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> - val overlayReference = handleLocalReferences(overlay.uri) - - downloadManagerClient.downloadMediaWithOverlay( - original = InputMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ), - overlay = InputMedia( - overlayReference, - DownloadMediaType.fromUri(Uri.parse(overlayReference)), - overlay.encryption?.toKeyPair(), - isOverlay = true - ) - ) - return - } - - downloadManagerClient.downloadSingleMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ) - } - - /** - * Handles the media from the opera viewer - * - * @param paramMap the parameters from the opera viewer - * @param mediaInfoMap the media info map - * @param forceDownload if the media should be downloaded - */ - private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>, - forceDownload: Boolean - ) { - //messages - paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || canAutoDownload("friend_snaps") }?.let { id -> - val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() - val conversationMessage = context.database.getConversationMessageFromId(messageId)!! - - val senderId = conversationMessage.senderId!! - val conversationId = conversationMessage.clientConversationId!! - - if (!forceDownload && !canUseRule(senderId)) { - return - } - - if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && senderId == context.database.myUserId) return - - val author = context.database.getFriendInfo(senderId) ?: return - val authorUsername = author.usernameForSorting!! - val mediaId = paramMap["MEDIA_ID"]?.toString()?.split("-")?.getOrNull(1) ?: "" - - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", - mediaAuthor = authorUsername, - downloadSource = MediaDownloadSource.CHAT_MEDIA, - friendInfo = author - ), mediaInfoMap) - - return - } - - //private stories - paramMap["PLAYLIST_V2_GROUP"]?.takeIf { - forceDownload || canAutoDownload("friend_stories") - }?.let { playlistGroup -> - val playlistGroupString = playlistGroup.toString() - - val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: if (playlistGroupString.contains("storyUserId=")) { - (playlistGroupString.indexOf("storyUserId=") + 12).let { - playlistGroupString.substring(it, playlistGroupString.indexOf(",", it)) - } - } else { - //story replies - val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } - ?.invoke(playlistGroup)?.toString() - ?.split(":")?.getOrNull(2) ?: return@let - - val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let - val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let - - conversationParticipants.firstOrNull { it != conversationMessage.senderId } - } - - val author = context.database.getFriendInfo( - if (storyUserId == null || storyUserId == "null") - context.database.myUserId - else storyUserId - ) ?: throw Exception("Friend not found in database") - val authorName = author.usernameForSorting!! - - 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(), - mediaAuthor = authorName, - downloadSource = MediaDownloadSource.STORY, - friendInfo = author - ), mediaInfoMap) - return - } - - val snapSource = paramMap["SNAP_SOURCE"].toString() - - //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() - - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaAuthor = userDisplayName, - downloadSource = MediaDownloadSource.PUBLIC_STORY, - ), mediaInfoMap) - return - } - - //spotlight - if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - downloadSource = MediaDownloadSource.SPOTLIGHT, - mediaAuthor = paramMap["TIME_STAMP"].toString() - ), mediaInfoMap) - return - } - - //stories with mpeg dash media - 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 - } - - fun prettyPrintTime(time: Long): String { - val seconds = time / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - return "${hours % 24}:${minutes % 60}:${seconds % 60}" - } - - 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")) - - urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> - it.substring(validIndex) - } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } - - context.runOnUiThread { - val selectedChapters = mutableListOf<Int>() - 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) - } - } - 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") { _, _ -> - val groups = mutableListOf<MutableList<SnapChapterInfo>>() - var currentGroup = mutableListOf<SnapChapterInfo>() - 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() - } - } - } - - private fun canAutoDownload(keyFilter: String? = null): Boolean { - val options by context.config.downloader.autoDownloadSources - return options.any { keyFilter == null || it.contains(keyFilter, true) } - } - - override fun asyncOnActivityCreate() { - val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "class") - - val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> - - val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString() - if (viewState != "FULLY_DISPLAYED") { - return@onOperaViewStateCallback - } - val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) as ArrayList<*> - val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap - - if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) - return@onOperaViewStateCallback - - val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() - val isVideo = mediaParamMap.containsKey("video_media_info_list") - - mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( - (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! - ) - - if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[SplitMediaAssetType.OVERLAY] = - MediaInfo(mediaParamMap["overlay_image_media_info"]!!) - } - lastSeenMapParams = mediaParamMap - lastSeenMediaInfoMap = mediaInfoMap - - if (!canAutoDownload()) return@onOperaViewStateCallback - - context.executeAsync { - runCatching { - handleOperaMedia(mediaParamMap, mediaInfoMap, false) - }.onFailure { - context.log.error("Failed to handle opera media", it) - context.longToast(it.message) - } - } - } - - arrayOf("onDisplayStateChange", "onDisplayStateChangeGesture").forEach { methodName -> - Hooker.hook( - operaViewerControllerClass, - context.mappings.getMappedValue("OperaPageViewController", methodName), - HookStage.AFTER, onOperaViewStateCallback - ) - } - } - - private fun downloadMessageAttachments( - friendInfo: FriendInfo, - message: ConversationMessage, - authorName: String, - attachments: List<DecodedAttachment> - ) { - //TODO: stickers - attachments.forEach { attachment -> - runCatching { - provideDownloadManagerClient( - mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.mediaUniqueId}", - downloadSource = MediaDownloadSource.CHAT_MEDIA, - mediaAuthor = authorName, - friendInfo = friendInfo - ).downloadSingleMedia( - mediaData = attachment.mediaUrlKey!!, - 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) { - val messageLogger = context.feature(MessageLogger::class) - val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") - - //get the message author - val friendInfo: FriendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") - val authorName = friendInfo.usernameForSorting!! - - val decodedAttachments = messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { - MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) - } ?: MessageDecoder.decode( - protoReader = ProtoReader(message.messageContent!!) - ) - - if (decodedAttachments.isEmpty()) { - context.shortToast(translations["no_attachments_toast"]) - return - } - - if (!isPreview) { - if (decodedAttachments.size == 1 || - context.mainActivity == null // we can't show alert dialogs when it downloads from a notification, so it downloads the first one - ) { - downloadMessageAttachments(friendInfo, message, authorName, - listOf(decodedAttachments.first()) - ) - return - } - - runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { - val selectedAttachments = mutableListOf<Int>().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() - } - - return - } - - runBlocking { - val firstAttachment = decodedAttachments.first() - - val previewCoroutine = async { - val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(Base64.decode(firstAttachment.mediaUrlKey!!), decryptionCallback = { - firstAttachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it - }) ?: return@async null - - val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() - - MediaDownloaderHelper.getSplitElements(ByteArrayInputStream(downloadedMedia)) { - type, inputStream -> - downloadedMediaList[type] = inputStream.readBytes() - } - - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - - if (bitmap == null) { - context.shortToast(translations["failed_to_create_preview_toast"]) - return@async null - } - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - bitmap - } - - 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() - } - - 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) - window?.setLayout( - context.resources.displayMetrics.widthPixels, - context.resources.displayMetrics.heightPixels - ) - } - previewCoroutine.start() - } - } - } - } - - fun downloadProfilePicture(url: String, author: String) { - provideDownloadManagerClient( - mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), - mediaAuthor = author, - downloadSource = MediaDownloadSource.PROFILE_PICTURE - ).downloadSingleMedia( - url, - DownloadMediaType.REMOTE_MEDIA - ) - } - - /** - * Called when a message is focused in chat - */ - fun onMessageActionMenu(isPreviewMode: Boolean) { - val messaging = context.feature(Messaging::class) - if (messaging.openedConversationUUID == null) return - downloadMessageId(messaging.lastFocusedMessageId, isPreviewMode) - } -} 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 @@ -1,80 +0,0 @@ -package me.rhunk.snapenhance.features.impl.downloader - -import android.annotation.SuppressLint -import android.widget.Button -import android.widget.RelativeLayout -import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent -import me.rhunk.snapenhance.core.event.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 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 - - context.event.subscribe(AddViewEvent::class) { event -> - if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe - - event.parent.addView(Button(event.parent.context).apply { - 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) - } - setOnClickListener { - ViewAppearanceHelper.newAlertDialogBuilder( - this@ProfilePictureDownloader.context.mainActivity!! - ).apply { - setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"]) - val choices = mutableMapOf<String, String>() - backgroundUrl?.let { choices["avatar_option"] = it } - avatarUrl?.let { choices["background_option"] = it } - - 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), - friendUsername!! - ) - }.onFailure { - this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it) - } - } - }.show() - } - }) - } - - - context.event.subscribe(NetworkApiRequestEvent::class) { event -> - if (!event.url.endsWith("/rpc/getPublicProfile")) return@subscribe - Hooker.ephemeralHookObjectMethod(event.callback::class.java, event.callback, "onSucceeded", HookStage.BEFORE) { methodParams -> - val content = methodParams.arg<ByteBuffer>(2).run { - ByteArray(capacity()).also { - get(it) - position(0) - } - } - - ProtoReader(content).followPath(1, 1, 2) { - friendUsername = getString(2) ?: return@followPath - followPath(4) { - backgroundUrl = getString(2) - avatarUrl = getString(100) - } - } - } - } - } -}- \ No newline at end of file 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 @@ -1,15 +0,0 @@ -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<Int, Int>? = 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 @@ -1,11 +0,0 @@ -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 @@ -1,191 +0,0 @@ -package me.rhunk.snapenhance.features.impl.downloader.decoder - -import com.google.gson.GsonBuilder -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import me.rhunk.snapenhance.core.download.data.toKeyPair -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.data.wrapper.impl.MessageContent -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -data class DecodedAttachment( - val mediaUrlKey: String?, - val type: AttachmentType, - val attachmentInfo: AttachmentInfo? -) { - @OptIn(ExperimentalEncodingApi::class) - val mediaUniqueId: String? by lazy { - runCatching { Base64.UrlSafe.decode(mediaUrlKey.toString()) }.getOrNull()?.let { ProtoReader(it).getString(2, 2) } - } -} - -@OptIn(ExperimentalEncodingApi::class) -object MessageDecoder { - private val gson = GsonBuilder().create() - - 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 - ) - } - - @OptIn(ExperimentalEncodingApi::class) - fun getEncodedMediaReferences(messageContent: JsonElement): List<String> { - return getMediaReferences(messageContent).map { reference -> - Base64.UrlSafe.encode( - reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() - ) - } - .toList() - } - - fun getMediaReferences(messageContent: JsonElement): List<JsonElement> { - return messageContent.asJsonObject.getAsJsonArray("mRemoteMediaReferences") - .asSequence() - .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } - .flatten() - .sortedBy { - it.asJsonObject["mMediaListId"].asLong - }.toList() - } - - - fun decode(messageContent: MessageContent): List<DecodedAttachment> { - return decode( - ProtoReader(messageContent.content), - customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) - ) - } - - fun decode(messageContent: JsonObject): List<DecodedAttachment> { - return decode( - ProtoReader(messageContent.getAsJsonArray("mContent") - .map { it.asByte } - .toByteArray()), - customMediaReferences = getEncodedMediaReferences(messageContent) - ) - } - - fun decode( - protoReader: ProtoReader, - customMediaReferences: List<String>? = null // when customReferences is null it means that the message is from arroyo database - ): List<DecodedAttachment> { - val decodedAttachment = mutableListOf<DecodedAttachment>() - val mediaReferences = mutableListOf<String>() - customMediaReferences?.let { mediaReferences.addAll(it) } - var mediaKeyIndex = 0 - - fun decodeMedia(type: AttachmentType, protoReader: ProtoReader) { - decodedAttachment.add( - DecodedAttachment( - mediaUrlKey = 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( - mediaUrlKey = 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( - mediaUrlKey = 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 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 @@ -1,55 +0,0 @@ -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<Any>(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/features/impl/experiments/AmoledDarkMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AmoledDarkMode.kt @@ -1,49 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import android.annotation.SuppressLint -import android.content.res.TypedArray -import android.graphics.drawable.ColorDrawable -import me.rhunk.snapenhance.Constants -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 AmoledDarkMode : Feature("Amoled Dark Mode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - @SuppressLint("DiscouragedApi") - override fun onActivityCreate() { - if (!context.config.userInterface.amoledDarkMode.get()) return - val attributeCache = mutableMapOf<String, Int>() - - 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 } - } - - context.androidContext.theme.javaClass.getMethod("obtainStyledAttributes", IntArray::class.java).hook(HookStage.AFTER) { param -> - val array = param.arg<IntArray>(0) - val result = param.getResult() as TypedArray - - fun ephemeralHook(methodName: String, content: Any) { - Hooker.ephemeralHookObjectMethod(result::class.java, result, methodName, HookStage.BEFORE) { - it.setResult(content) - } - } - - when (array[0]) { - getAttribute("sigColorTextPrimary") -> { - ephemeralHook("getColor", 0xFFFFFFFF.toInt()) - } - getAttribute("sigColorBackgroundMain"), - getAttribute("sigColorBackgroundSurface") -> { - ephemeralHook("getColor", 0xFF000000.toInt()) - } - getAttribute("actionSheetBackgroundDrawable"), - getAttribute("actionSheetRoundedBackgroundDrawable") -> { - ephemeralHook("getDrawable", ColorDrawable(0xFF000000.toInt())) - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/AppPasscode.kt @@ -1,106 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import android.annotation.SuppressLint -import android.content.Context -import android.os.Build -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.ui.ViewAppearanceHelper - -//TODO: fingerprint unlock -class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private var isLocked = false - - private fun setActivityVisibility(isVisible: Boolean) { - context.mainActivity?.let { - it.window.attributes = it.window.attributes.apply { alpha = if (isVisible) 1.0F else 0.0F } - } - } - - fun lock() { - if (isLocked) return - isLocked = true - val passcode by context.config.experimental.appPasscode.also { - if (it.getNullable()?.isEmpty() != false) return - } - val isDigitPasscode = passcode.all { it.isDigit() } - - val mainActivity = context.mainActivity!! - setActivityVisibility(false) - - val prompt = ViewAppearanceHelper.newAlertDialogBuilder(mainActivity) - val createPrompt = { - val alertDialog = prompt.create() - val textView = EditText(mainActivity) - textView.setSingleLine() - textView.inputType = if (isDigitPasscode) { - (InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD) - } else { - (InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - } - textView.hint = "Code :" - textView.setPadding(100, 100, 100, 100) - - textView.addTextChangedListener(object: TextWatcher { - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - if (s.contentEquals(passcode)) { - alertDialog.dismiss() - isLocked = false - setActivityVisibility(true) - } - } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun afterTextChanged(s: Editable?) {} - }) - - alertDialog.setView(textView) - - textView.viewTreeObserver.addOnWindowFocusChangeListener { hasFocus -> - if (!hasFocus) return@addOnWindowFocusChangeListener - val imm = mainActivity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(textView, InputMethodManager.SHOW_IMPLICIT) - } - - alertDialog.window?.let { - it.attributes.verticalMargin = -0.18F - } - - alertDialog.show() - textView.requestFocus() - } - - prompt.setOnCancelListener { - createPrompt() - } - - createPrompt() - } - - @SuppressLint("MissingPermission") - override fun onActivityCreate() { - if (!context.database.hasArroyo()) return - - context.runOnUiThread { - lock() - } - - if (!context.config.experimental.appLockOnResume.get()) return - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - context.mainActivity?.registerActivityLifecycleCallbacks(object: android.app.Application.ActivityLifecycleCallbacks { - override fun onActivityPaused(activity: android.app.Activity) { lock() } - override fun onActivityResumed(activity: android.app.Activity) {} - override fun onActivityStarted(activity: android.app.Activity) {} - override fun onActivityDestroyed(activity: android.app.Activity) {} - override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} - override fun onActivityStopped(activity: android.app.Activity) {} - override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} - }) - } - } -}- \ No newline at end of file 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 @@ -1,93 +0,0 @@ -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.Hooker - -class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - if (context.config.experimental.spoof.globalState != true) return - - 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 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) - context.log.verbose("Fingerprint spoofed to $fingerprint") - } - Hooker.hook(fingerprintClass, "deriveFingerprint", HookStage.BEFORE) { hookAdapter -> - hookAdapter.setResult(fingerprint) - context.log.verbose("Fingerprint spoofed to $fingerprint") - } - } - - //ANDROID ID - if (androidId.isNotEmpty()) { - Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter -> - if(hookAdapter.args()[1] == "android_id") { - hookAdapter.setResult(androidId) - context.log.verbose("Android ID spoofed to $androidId") - } - } - } - - //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) - } - } - - //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 diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/EndToEndEncryption.kt @@ -1,500 +0,0 @@ -package me.rhunk.snapenhance.features.impl.experiments - -import android.annotation.SuppressLint -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.Shape -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Button -import android.widget.TextView -import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent -import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent -import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent -import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.core.messaging.RuleState -import me.rhunk.snapenhance.core.util.EvictingMap -import me.rhunk.snapenhance.core.util.ktx.getObjectField -import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.wrapper.impl.MessageContent -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.MessagingRuleFeature -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.ui.addForegroundDrawable -import me.rhunk.snapenhance.ui.removeForegroundDrawable -import java.security.MessageDigest -import kotlin.random.Random - -class EndToEndEncryption : MessagingRuleFeature( - "EndToEndEncryption", - MessagingRuleType.E2E_ENCRYPTION, - loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_SYNC or FeatureLoadParams.INIT_ASYNC -) { - private val isEnabled get() = context.config.experimental.e2eEncryption.globalState == true - private val e2eeInterface by lazy { context.bridgeClient.getE2eeInterface() } - - companion object { - const val REQUEST_PK_MESSAGE_ID = 1 - const val RESPONSE_SK_MESSAGE_ID = 2 - const val ENCRYPTED_MESSAGE_ID = 3 - } - - private val decryptedMessageCache = EvictingMap<Long, Pair<ContentType, ByteArray>>(100) - - private val pkRequests = mutableMapOf<Long, ByteArray>() - private val secretResponses = mutableMapOf<Long, ByteArray>() - private val encryptedMessages = mutableListOf<Long>() - - private fun getE2EParticipants(conversationId: String): List<String> { - return context.database.getConversationParticipants(conversationId)?.filter { friendId -> e2eeInterface.friendKeyExists(friendId) } ?: emptyList() - } - - private fun askForKeys(conversationId: String) { - val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { - context.longToast("Can't find friendId for conversationId $conversationId") - return - } - - val publicKey = e2eeInterface.createKeyExchange(friendId) ?: run { - context.longToast("Can't create key exchange for friendId $friendId") - return - } - - context.log.verbose("created publicKey: ${publicKey.contentToString()}") - - sendCustomMessage(conversationId, REQUEST_PK_MESSAGE_ID) { - addBuffer(2, publicKey) - } - } - - private fun sendCustomMessage(conversationId: String, messageId: Int, message: ProtoWriter.() -> Unit) { - context.messageSender.sendCustomChatMessage( - listOf(SnapUUID.fromString(conversationId)), - ContentType.CHAT, - message = { - from(2) { - from(1) { - addVarInt(1, messageId) - addBuffer(2, ProtoWriter().apply(message).toByteArray()) - } - } - } - ) - } - - private fun warnKeyOverwrite(friendId: String, block: () -> Unit) { - if (!e2eeInterface.friendKeyExists(friendId)) { - block() - return - } - - context.mainActivity?.runOnUiThread { - val mainActivity = context.mainActivity ?: return@runOnUiThread - ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { - setTitle("End-to-end encryption") - setMessage("WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?") - setPositiveButton("Yes") { _, _ -> - ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { - setTitle("End-to-end encryption") - setMessage("Are you REALLY sure you want to continue? This is your last chance to back out.") - setNeutralButton("Yes") { _, _ -> block() } - setPositiveButton("No") { _, _ -> } - }.show() - } - setNegativeButton("No") { _, _ -> } - }.show() - } - } - - private fun handlePublicKeyRequest(conversationId: String, publicKey: ByteArray) { - val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { - context.longToast("Can't find friendId for conversationId $conversationId") - return - } - warnKeyOverwrite(friendId) { - val encapsulatedSecret = e2eeInterface.acceptPairingRequest(friendId, publicKey) - if (encapsulatedSecret == null) { - context.longToast("Failed to accept public key") - return@warnKeyOverwrite - } - context.longToast("Public key successfully accepted") - - sendCustomMessage(conversationId, RESPONSE_SK_MESSAGE_ID) { - addBuffer(2, encapsulatedSecret) - } - } - } - - private fun handleSecretResponse(conversationId: String, secret: ByteArray) { - val friendId = context.database.getDMOtherParticipant(conversationId) ?: run { - context.longToast("Can't find friendId for conversationId $conversationId") - return - } - warnKeyOverwrite(friendId) { - context.log.verbose("handleSecretResponse, secret = $secret") - val result = e2eeInterface.acceptPairingResponse(friendId, secret) - if (!result) { - context.longToast("Failed to accept secret") - return@warnKeyOverwrite - } - context.longToast("Done! You can now exchange encrypted messages with this friend.") - } - } - - private fun openManagementPopup() { - val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return - val friendId = context.database.getDMOtherParticipant(conversationId) - - if (friendId == null) { - context.shortToast("This menu is only available in direct messages.") - return - } - - val actions = listOf( - "Initiate a new shared secret", - "Show shared key fingerprint" - ) - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { - setTitle("End-to-end encryption") - setItems(actions.toTypedArray()) { _, which -> - when (which) { - 0 -> { - warnKeyOverwrite(friendId) { - askForKeys(conversationId) - } - } - 1 -> { - val fingerprint = e2eeInterface.getSecretFingerprint(friendId) - ViewAppearanceHelper.newAlertDialogBuilder(context).apply { - setTitle("End-to-end encryption") - setMessage("Your fingerprint is:\n\n$fingerprint\n\nMake sure to check if it matches your friend's fingerprint!") - setPositiveButton("OK") { _, _ -> } - }.show() - } - } - } - setPositiveButton("OK") { _, _ -> } - }.show() - } - - @SuppressLint("SetTextI18n", "DiscouragedApi") - override fun onActivityCreate() { - if (!isEnabled) return - // add button to input bar - context.event.subscribe(AddViewEvent::class) { param -> - if (param.view.toString().contains("default_input_bar")) { - (param.view as ViewGroup).addView(TextView(param.view.context).apply { - layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT) - setOnClickListener { openManagementPopup() } - setPadding(20, 20, 20, 20) - textSize = 23f - text = "\uD83D\uDD12" - }) - } - } - - val encryptedMessageIndicator by context.config.experimental.e2eEncryption.encryptedMessageIndicator - - // hook view binder to add special buttons - val receivePublicKeyTag = Random.nextLong().toString(16) - val receiveSecretTag = Random.nextLong().toString(16) - - context.event.subscribe(BindViewEvent::class) { event -> - event.chatMessage { conversationId, messageId -> - val viewGroup = event.view as ViewGroup - - viewGroup.findViewWithTag<View>(receiveSecretTag)?.also { - viewGroup.removeView(it) - } - - viewGroup.findViewWithTag<View>(receivePublicKeyTag)?.also { - viewGroup.removeView(it) - } - - if (encryptedMessageIndicator) { - viewGroup.removeForegroundDrawable("encryptedMessage") - - if (encryptedMessages.contains(messageId.toLong())) { - viewGroup.addForegroundDrawable("encryptedMessage", ShapeDrawable(object: Shape() { - override fun draw(canvas: Canvas, paint: Paint) { - paint.textSize = 20f - canvas.drawText("\uD83D\uDD12", 0f, canvas.height / 2f, paint) - } - })) - } - } - - secretResponses[messageId.toLong()]?.also { secret -> - viewGroup.addView(Button(context.mainActivity!!).apply { - text = "Accept secret" - tag = receiveSecretTag - setOnClickListener { - handleSecretResponse(conversationId, secret) - } - }) - } - - pkRequests[messageId.toLong()]?.also { publicKey -> - viewGroup.addView(Button(context.mainActivity!!).apply { - text = "Receive public key" - tag = receivePublicKeyTag - setOnClickListener { - handlePublicKeyRequest(conversationId, publicKey) - } - }) - } - } - } - } - - private fun fixContentType(contentType: ContentType?, message: ProtoReader) - = ContentType.fromMessageContainer(message) ?: contentType - - private fun hashParticipantId(participantId: String, salt: ByteArray): ByteArray { - return MessageDigest.getInstance("SHA-256").apply { - update(participantId.toByteArray()) - update(salt) - }.digest() - } - - private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent) { - if (messageContent.contentType != ContentType.STATUS && decryptedMessageCache.containsKey(messageId)) { - val (contentType, buffer) = decryptedMessageCache[messageId]!! - messageContent.contentType = contentType - messageContent.content = buffer - return - } - - val reader = ProtoReader(messageContent.content) - messageContent.contentType = fixContentType(messageContent.contentType!!, reader) - - fun setMessageContent(buffer: ByteArray) { - messageContent.content = buffer - messageContent.contentType = fixContentType(messageContent.contentType, ProtoReader(buffer)) - decryptedMessageCache[messageId] = messageContent.contentType!! to buffer - } - - fun replaceMessageText(text: String) { - messageContent.content = ProtoWriter().apply { - from(2) { - addString(1, text) - } - }.toByteArray() - } - - // decrypt messages - reader.followPath(2, 1) { - val messageTypeId = getVarInt(1)?.toInt() ?: return@followPath - val isMe = context.database.myUserId == senderId - val conversationParticipants by lazy { - getE2EParticipants(conversationId) - } - - if (messageTypeId == ENCRYPTED_MESSAGE_ID) { - runCatching { - eachBuffer(2) { - val participantIdHash = getByteArray(1) ?: return@eachBuffer - val iv = getByteArray(2) ?: return@eachBuffer - val ciphertext = getByteArray(3) ?: return@eachBuffer - - if (isMe) { - if (conversationParticipants.isEmpty()) return@eachBuffer - val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer - setMessageContent( - e2eeInterface.decryptMessage(participantId, ciphertext, iv) - ) - encryptedMessages.add(messageId) - return@eachBuffer - } - - if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer - - setMessageContent( - e2eeInterface.decryptMessage(senderId, ciphertext, iv) - ) - encryptedMessages.add(messageId) - } - }.onFailure { - context.log.error("Failed to decrypt message id: $messageId", it) - messageContent.contentType = ContentType.CHAT - messageContent.content = ProtoWriter().apply { - from(2) { - addString(1, "Failed to decrypt message, id=$messageId. Check logcat for more details.") - } - }.toByteArray() - } - - return@followPath - } - - val payload = getByteArray(2, 2) ?: return@followPath - - if (senderId == context.database.myUserId) { - when (messageTypeId) { - REQUEST_PK_MESSAGE_ID -> { - replaceMessageText("[Key exchange request]") - } - RESPONSE_SK_MESSAGE_ID -> { - replaceMessageText("[Key exchange response]") - } - } - return@followPath - } - - when (messageTypeId) { - REQUEST_PK_MESSAGE_ID -> { - pkRequests[messageId] = payload - replaceMessageText("You just received a public key request. Click below to accept it.") - } - RESPONSE_SK_MESSAGE_ID -> { - secretResponses[messageId] = payload - replaceMessageText("Your friend just accepted your public key. Click below to accept the secret.") - } - } - } - } - - override fun asyncInit() { - if (!isEnabled) return - val forceMessageEncryption by context.config.experimental.e2eEncryption.forceMessageEncryption - - // trick to disable fidelius encryption - context.event.subscribe(SendMessageWithContentEvent::class) { event -> - val messageContent = event.messageContent - val destinations = event.destinations - - val e2eeConversations = destinations.conversations.filter { getState(it.toString()) } - - if (e2eeConversations.isEmpty()) return@subscribe - - if (e2eeConversations.size != destinations.conversations.size) { - if (!forceMessageEncryption) return@subscribe - context.longToast("You can't send encrypted content to both encrypted and unencrypted conversations!") - event.canceled = true - return@subscribe - } - - event.addInvokeLater { - if (messageContent.contentType == ContentType.SNAP) { - messageContent.contentType = ContentType.EXTERNAL_MEDIA - } - - if (messageContent.contentType == ContentType.CHAT) { - messageContent.contentType = ContentType.SHARE - } - } - } - - context.event.subscribe(UnaryCallEvent::class) { event -> - if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe - val protoReader = ProtoReader(event.buffer) - - val conversationIds = mutableListOf<SnapUUID>() - protoReader.eachBuffer(3) { - conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer)) - } - - if (conversationIds.any { !getState(it.toString()) }) { - context.log.debug("Skipping encryption for conversation ids: ${conversationIds.joinToString(", ")}") - return@subscribe - } - - val participantsIds = conversationIds.map { getE2EParticipants(it.toString()) }.flatten().distinct() - - if (participantsIds.isEmpty()) { - context.longToast("You don't have any friends in this conversation to encrypt messages with!") - return@subscribe - } - val messageReader = protoReader.followPath(4) ?: return@subscribe - - if (messageReader.getVarInt(4, 2, 1, 1) != null) { - return@subscribe - } - - event.buffer = ProtoEditor(event.buffer).apply { - val contentType = fixContentType( - ContentType.fromId(messageReader.getVarInt(2)?.toInt() ?: -1), - messageReader.followPath(4) ?: return@apply - ) ?: return@apply - val messageContent = messageReader.getByteArray(4) ?: return@apply - - runCatching { - edit(4) { - //set message content type - remove(2) - addVarInt(2, contentType.id) - - //set encrypted content - remove(4) - add(4) { - from(2) { - from(1) { - addVarInt(1, ENCRYPTED_MESSAGE_ID) - participantsIds.forEach { participantId -> - val encryptedMessage = e2eeInterface.encryptMessage(participantId, - messageContent - ) ?: run { - context.log.error("Failed to encrypt message for $participantId") - return@forEach - } - context.log.debug("encrypted message size = ${encryptedMessage.ciphertext.size} for $participantId") - from(2) { - // participantId is hashed with iv to prevent leaking it when sending to multiple conversations - addBuffer(1, hashParticipantId(participantId, encryptedMessage.iv)) - addBuffer(2, encryptedMessage.iv) - addBuffer(3, encryptedMessage.ciphertext) - } - } - } - } - } - } - }.onFailure { - event.canceled = true - context.log.error("Failed to encrypt message", it) - context.longToast("Failed to encrypt message! Check logcat for more details.") - } - }.toByteArray() - } - } - - override fun init() { - if (!isEnabled) return - - context.event.subscribe(BuildMessageEvent::class, priority = 0) { event -> - val message = event.message - val conversationId = message.messageDescriptor.conversationId.toString() - messageHook( - conversationId = conversationId, - messageId = message.messageDescriptor.messageId, - senderId = message.senderId.toString(), - messageContent = message.messageContent - ) - - message.messageContent.instanceNonNull() - .getObjectField("mQuotedMessage") - ?.getObjectField("mContent") - ?.also { quotedMessage -> - messageHook( - conversationId = conversationId, - messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also, - senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(), - messageContent = MessageContent(quotedMessage) - ) - } - } - } - - override fun getRuleState() = RuleState.WHITELIST -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/InfiniteStoryBoost.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/InfiniteStoryBoost.kt @@ -1,23 +0,0 @@ -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.hookConstructor - -class InfiniteStoryBoost : Feature("InfiniteStoryBoost", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val storyBoostStateClass = context.mappings.getMappedClass("StoryBoostStateClass") - - storyBoostStateClass.hookConstructor(HookStage.BEFORE, { - context.config.experimental.infiniteStoryBoost.get() - }) { param -> - val startTimeMillis = param.arg<Long>(1) - //reset timestamp if it's more than 24 hours - if (System.currentTimeMillis() - startTimeMillis > 86400000) { - param.setArg(1, 0) - param.setArg(2, 0) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/MeoPasscodeBypass.kt @@ -1,22 +0,0 @@ -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.Hooker - -class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val bcrypt = context.mappings.getMappedMap("BCrypt") - - Hooker.hook( - context.androidContext.classLoader.loadClass(bcrypt["class"].toString()), - bcrypt["hashMethod"].toString(), - HookStage.BEFORE, - { context.config.experimental.meoPasscodeBypass.get() }, - ) { param -> - //set the hash to the result of the method - param.setResult(param.arg(1)) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/NoFriendScoreDelay.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/experiments/NoFriendScoreDelay.kt @@ -1,20 +0,0 @@ -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.hookConstructor -import java.lang.reflect.Constructor - -class NoFriendScoreDelay : Feature("NoFriendScoreDelay", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - if (!context.config.experimental.noFriendScoreDelay.get()) return - val scoreUpdateClass = context.mappings.getMappedClass("ScoreUpdate") - - scoreUpdateClass.hookConstructor(HookStage.BEFORE) { param -> - val constructor = param.method() as Constructor<*> - if (constructor.parameterTypes.size < 3 || constructor.parameterTypes[3] != java.util.Collection::class.java) return@hookConstructor - param.setArg(2, 0L) - } - } -}- \ No newline at end of file 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 @@ -1,24 +0,0 @@ -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 - -class UnlimitedMultiSnap : Feature("UnlimitedMultiSnap", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - android.util.Pair::class.java.hookConstructor(HookStage.AFTER, { - context.config.experimental.unlimitedMultiSnap.get() - }) { param -> - val first = param.arg<Any>(0) - val second = param.arg<Any>(1) - if ( - first == true && // isOverTheLimit - second == 8 // limit - ) { - param.thisObject<Any>().setObjectField("first", false) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt @@ -1,30 +0,0 @@ -package me.rhunk.snapenhance.features.impl.privacy - -import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class 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")) { - param.canceled = true - } - } - } -}- \ No newline at end of file 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 @@ -1,48 +0,0 @@ -package me.rhunk.snapenhance.features.impl.privacy - -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent -import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor -import me.rhunk.snapenhance.data.NotificationType -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 PreventMessageSending : Feature("Prevent message sending", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - val preventMessageSending by context.config.messaging.preventMessageSending - - context.event.subscribe(UnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event -> - if (event.uri != "/messagingcoreservice.MessagingCoreService/UpdateContentMessage") return@subscribe - event.buffer = ProtoEditor(event.buffer).apply { - edit(3) { - // replace replayed to read receipt - remove(13) - addBuffer(4, byteArrayOf()) - } - }.toByteArray() - } - - context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> - val messageUpdate = param.arg<Any>(2).toString() - if (messageUpdate == "SCREENSHOT" && preventMessageSending.contains("chat_screenshot")) { - param.setResult(null) - } - - if (messageUpdate == "SCREEN_RECORD" && preventMessageSending.contains("chat_screen_record")) { - param.setResult(null) - } - } - - context.event.subscribe(SendMessageWithContentEvent::class) { event -> - val contentType = event.messageContent.contentType - val associatedType = NotificationType.fromContentType(contentType ?: return@subscribe) ?: return@subscribe - - if (preventMessageSending.contains(associatedType.key)) { - context.log.verbose("Preventing message sending for $associatedType") - event.canceled = true - } - } - } -}- \ No newline at end of file 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 @@ -1,27 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent -import me.rhunk.snapenhance.core.util.download.HttpServer -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -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/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -1,179 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.Shape -import android.os.DeadObjectException -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent -import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent -import me.rhunk.snapenhance.core.util.EvictingMap -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.MessageState -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.ui.addForegroundDrawable -import me.rhunk.snapenhance.ui.removeForegroundDrawable -import java.util.concurrent.Executors -import kotlin.system.measureTimeMillis - -private fun Any.longHashCode(): Long { - var h = 1125899906842597L - val value = this.toString() - for (element in value) h = 31 * h + element.code.toLong() - return h -} - -class MessageLogger : Feature("MessageLogger", - loadParams = FeatureLoadParams.INIT_SYNC or - FeatureLoadParams.ACTIVITY_CREATE_SYNC or - FeatureLoadParams.ACTIVITY_CREATE_ASYNC -) { - companion object { - const val PREFETCH_MESSAGE_COUNT = 20 - const val PREFETCH_FEED_COUNT = 20 - const val DELETED_MESSAGE_COLOR = 0x2Eb71c1c - } - - private val messageLoggerInterface by lazy { context.bridgeClient.getMessageLogger() } - - val isEnabled get() = context.config.messaging.messageLogger.get() - - private val threadPool = Executors.newFixedThreadPool(10) - - private val cachedIdLinks = mutableMapOf<Long, Long>() // client id -> server id - private val fetchedMessages = mutableListOf<Long>() // list of unique message ids - private val deletedMessageCache = EvictingMap<Long, JsonObject>(200) // unique message id -> message json object - - fun isMessageDeleted(conversationId: String, clientMessageId: Long) - = makeUniqueIdentifier(conversationId, clientMessageId)?.let { deletedMessageCache.containsKey(it) } ?: false - - fun deleteMessage(conversationId: String, clientMessageId: Long) { - val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return - fetchedMessages.remove(uniqueMessageId) - deletedMessageCache.remove(uniqueMessageId) - messageLoggerInterface.deleteMessage(conversationId, uniqueMessageId) - } - - fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? { - val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null - if (deletedMessageCache.containsKey(uniqueMessageId)) { - return deletedMessageCache[uniqueMessageId] - } - return messageLoggerInterface.getMessage(conversationId, uniqueMessageId)?.let { - JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject - } - } - - fun getMessageProto(conversationId: String, clientMessageId: Long): ProtoReader? { - return getMessageObject(conversationId, clientMessageId)?.let { message -> - ProtoReader(message.getAsJsonObject("mMessageContent").getAsJsonArray("mContent") - .map { it.asByte } - .toByteArray()) - } - } - - private fun computeMessageIdentifier(conversationId: String, orderKey: Long) = (orderKey.toString() + conversationId).longHashCode() - - private fun makeUniqueIdentifier(conversationId: String, clientMessageId: Long): Long? { - val serverMessageId = cachedIdLinks[clientMessageId] ?: - context.database.getConversationMessageFromId(clientMessageId)?.serverMessageId?.toLong()?.also { - cachedIdLinks[clientMessageId] = it - } - ?: return run { - context.log.error("Failed to get server message id for $conversationId $clientMessageId") - null - } - return computeMessageIdentifier(conversationId, serverMessageId) - } - - override fun asyncOnActivityCreate() { - if (!isEnabled || !context.database.hasArroyo()) { - return - } - - measureTimeMillis { - val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! } - if (conversationIds.isEmpty()) return@measureTimeMillis - fetchedMessages.addAll(messageLoggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList()) - }.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") } - } - - override fun init() { - context.event.subscribe(BuildMessageEvent::class, { isEnabled }, priority = 1) { event -> - 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() - //exclude messages sent by me - if (event.message.senderId.toString() == context.database.myUserId) return@subscribe - - val uniqueMessageIdentifier = computeMessageIdentifier(conversationId, event.message.orderKey) - - if (event.message.messageContent.contentType != ContentType.STATUS) { - if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe - fetchedMessages.add(uniqueMessageIdentifier) - - threadPool.execute { - try { - messageLoggerInterface.addMessage(conversationId, uniqueMessageIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)) - } catch (ignored: DeadObjectException) {} - } - - return@subscribe - } - - //query the deleted message - val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier)) - deletedMessageCache[uniqueMessageIdentifier] - else { - messageLoggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let { - JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject - } - } ?: return@subscribe - - val messageJsonObject = deletedMessageObject.asJsonObject - - //if the message is a snap make it playable - if (messageJsonObject["mMessageContent"]?.asJsonObject?.get("mContentType")?.asString == "SNAP") { - messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") - } - - //serialize all properties of messageJsonObject and put in the message object - messageInstance.javaClass.declaredFields.forEach { field -> - field.isAccessible = true - if (field.name == "mDescriptor") return@forEach // prevent the client message id from being overwritten - messageJsonObject[field.name]?.let { fieldValue -> - field.set(messageInstance, context.gson.fromJson(fieldValue, field.type)) - } - } - - deletedMessageCache[uniqueMessageIdentifier] = deletedMessageObject - } - } - - override fun onActivityCreate() { - if (!isEnabled) return - - context.event.subscribe(BindViewEvent::class) { event -> - event.chatMessage { conversationId, messageId -> - event.view.removeForegroundDrawable("deletedMessage") - makeUniqueIdentifier(conversationId, messageId.toLong())?.let { serverMessageId -> - if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage - } ?: return@chatMessage - - event.view.addForegroundDrawable("deletedMessage", ShapeDrawable(object: Shape() { - override fun draw(canvas: Canvas, paint: Paint) { - canvas.drawRect(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), Paint().apply { - color = DELETED_MESSAGE_COLOR - }) - } - })) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/PreventReadReceipts.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/PreventReadReceipts.kt @@ -1,28 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker - -class PreventReadReceipts : Feature("PreventReadReceipts", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{ - context.feature(StealthMode::class).canUseRule(it.toString()) - } - - arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> - Hooker.hook(context.classCache.conversationManager, methodName, HookStage.BEFORE, { isConversationInStealthMode(SnapUUID(it.arg(0))) }) { - it.setResult(null) - } - } - - context.event.subscribe(OnSnapInteractionEvent::class) { event -> - if (isConversationInStealthMode(event.conversationId)) { - event.canceled = true - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/SnapToChatMedia.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/SnapToChatMedia.kt @@ -1,25 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams - -class SnapToChatMedia : Feature("SnapToChatMedia", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - if (!context.config.messaging.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/features/impl/spying/StealthMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/StealthMode.kt @@ -1,6 +0,0 @@ -package me.rhunk.snapenhance.features.impl.spying - -import me.rhunk.snapenhance.core.messaging.MessagingRuleType -import me.rhunk.snapenhance.features.MessagingRuleFeature - -class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH)- \ No newline at end of file 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 @@ -1,139 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -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 -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.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import java.util.concurrent.Executors - -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) } - 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() - } - - 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 - 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(), - messageId, - context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" }, - callback - ) - }.onFailure { - Logger.xposedLog("Error saving message $messageId", it) - } - - //delay between saves - Thread.sleep(100L) - } - - private fun canSaveMessage(message: Message): Boolean { - 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 { - 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 - } - - override fun asyncOnActivityCreate() { - //called when enter in a conversation (or when a message is sent) - Hooker.hook( - context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"), - "onFetchConversationWithMessagesComplete", - HookStage.BEFORE, - { autoSaveFilter.isNotEmpty() } - ) { param -> - val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")!!) - if (!canSaveInConversation(conversationId.toString())) return@hook - - val messages = param.arg<List<Any>>(1).map { Message(it) } - messages.forEach { - if (!canSaveMessage(it)) return@forEach - asyncSaveExecutorService.submit { - saveMessage(conversationId, it) - } - } - } - - //called when a message is received - Hooker.hook( - context.mappings.getMappedClass("callbacks", "FetchMessageCallback"), - "onFetchMessageComplete", - HookStage.BEFORE, - { autoSaveFilter.isNotEmpty() } - ) { param -> - val message = Message(param.arg(0)) - val conversationId = message.messageDescriptor.conversationId - if (!canSaveInConversation(conversationId.toString())) return@hook - if (!canSaveMessage(message)) return@hook - - asyncSaveExecutorService.submit { - saveMessage(conversationId, message) - } - } - - Hooker.hook( - context.mappings.getMappedClass("callbacks", "SendMessageCallback"), - "onSuccess", - HookStage.BEFORE, - { autoSaveFilter.isNotEmpty() } - ) { - val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() - val conversationUUID = messaging.openedConversationUUID ?: return@hook - runCatching { - fetchConversationWithMessagesPaginatedMethod.invoke( - messaging.conversationManager, conversationUUID.instanceNonNull(), - Long.MAX_VALUE, - 10, - callback - ) - }.onFailure { - Logger.xposedLog("failed to save message", it) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/BypassVideoLengthRestriction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/BypassVideoLengthRestriction.kt @@ -1,72 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.os.Build -import android.os.FileObserver -import com.google.gson.JsonParser -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -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 java.io.File - -class BypassVideoLengthRestriction : - Feature("BypassVideoLengthRestriction", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private lateinit var fileObserver: FileObserver - - override fun asyncOnActivityCreate() { - val mode = context.config.global.bypassVideoLengthRestriction.getNullable() - - if (mode == "single") { - //fix black videos when story is posted - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val postedStorySnapFolder = - File(context.androidContext.filesDir, "file_manager/posted_story_snap") - - fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) { - override fun onEvent(event: Int, path: String?) { - if (event != MOVED_TO || path?.endsWith("posted_story_snap.2") != true) return - fileObserver.stopWatching() - - val file = File(postedStorySnapFolder, path) - runCatching { - val fileContent = JsonParser.parseReader(file.reader()).asJsonObject - if (fileContent["timerOrDuration"].asLong < 0) file.delete() - }.onFailure { - context.log.error("Failed to read story metadata file", it) - } - } - }) - - context.event.subscribe(SendMessageWithContentEvent::class) { event -> - if (event.destinations.stories.isEmpty()) return@subscribe - fileObserver.startWatching() - } - } - - context.mappings.getMappedClass("DefaultMediaItem") - .hookConstructor(HookStage.BEFORE) { param -> - //set the video length argument - param.setArg(5, -1L) - } - } - - //TODO: allow split from any source - if (mode == "split") { - val cameraRollId = context.mappings.getMappedMap("CameraRollMediaId") - // memories grid - findClass(cameraRollId["class"].toString()).hookConstructor(HookStage.AFTER) { param -> - //set the durationMs field - param.thisObject<Any>() - .setObjectField(cameraRollId["durationMsField"].toString(), -1L) - } - - // chat camera roll grid - findClass("com.snap.impala.common.media.MediaLibraryItem").hookConstructor(HookStage.BEFORE) { param -> - //set the video length argument - param.setArg(3, -1L) - } - } - } -}- \ No newline at end of file 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 @@ -1,78 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -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.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 - -class CameraTweaks : Feature("Camera Tweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - companion object { - val resolutions = listOf("3264x2448", "3264x1840", "3264x1504", "2688x1512", "2560x1920", "2448x2448", "2340x1080", "2160x1080", "1920x1440", "1920x1080", "1600x1200", "1600x960", "1600x900", "1600x736", "1600x720", "1560x720", "1520x720", "1440x1080", "1440x720", "1280x720", "1080x1080", "1080x720", "960x720", "720x720", "720x480", "640x480", "352x288", "320x240", "176x144") - } - - private fun parseResolution(resolution: String): IntArray { - return resolution.split("x").map { it.toInt() }.toIntArray() - } - - @SuppressLint("MissingPermission", "DiscouragedApi") - override fun onActivityCreate() { - if (context.config.camera.disable.get()) { - ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> - val permission = param.arg<String>(0) - if (permission == Manifest.permission.CAMERA) { - param.setResult(PackageManager.PERMISSION_GRANTED) - } - } - - CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param -> - param.setResult(null) - } - } - - 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<Key<*>>(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)) - - if (previewResolution.isPresent() && captureResolution.isPresent()) { - previewResolutionConfig?.let { - previewResolution.first = it[0] - previewResolution.second = it[1] - } - - captureResolutionConfig?.let { - captureResolution.first = it[0] - captureResolution.second = it[1] - } - } - } - } -}- \ No newline at end of file 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 @@ -1,22 +0,0 @@ -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 - -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<Any>() - 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/GooglePlayServicesDialogs.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GooglePlayServicesDialogs.kt @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.app.AlertDialog -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.hook -import java.lang.reflect.Modifier - -class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - if (!context.config.global.disableGooglePlayDialogs.get()) return - - findClass("com.google.android.gms.common.GoogleApiAvailability").methods - .first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method -> - method.hook(HookStage.BEFORE) { param -> - context.log.verbose("GoogleApiAvailability.showErrorDialogFragment() called, returning null") - param.setResult(null) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/LocationSpoofer.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/LocationSpoofer.kt @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.content.Intent -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 LocationSpoofer: Feature("LocationSpoof", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - override fun asyncOnActivityCreate() { - Hooker.hook(context.mainActivity!!.javaClass, "onActivityResult", HookStage.BEFORE) { param -> - val intent = param.argNullable<Intent>(2) ?: return@hook - val bundle = intent.getBundleExtra("location") ?: return@hook - param.setResult(null) - - with(context.config.experimental.spoof.location) { - latitude.set(bundle.getFloat("latitude")) - longitude.set(bundle.getFloat("longitude")) - - context.longToast("Location set to $latitude, $longitude") - } - } - - if (context.config.experimental.spoof.location.globalState != true) return - - val latitude by context.config.experimental.spoof.location.latitude - val longitude by context.config.experimental.spoof.location.longitude - - val locationClass = android.location.Location::class.java - val locationManagerClass = android.location.LocationManager::class.java - - locationClass.hook("getLatitude", HookStage.BEFORE) { it.setResult(latitude.toDouble()) } - locationClass.hook("getLongitude", HookStage.BEFORE) { it.setResult(longitude.toDouble()) } - locationClass.hook("getAccuracy", HookStage.BEFORE) { it.setResult(0.0F) } - - //Might be redundant because it calls isProviderEnabledForUser which we also hook, meaning if isProviderEnabledForUser returns true this will also return true - locationManagerClass.hook("isProviderEnabled", HookStage.BEFORE) { it.setResult(true) } - locationManagerClass.hook("isProviderEnabledForUser", HookStage.BEFORE) { it.setResult(true) } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/MediaQualityLevelOverride.kt @@ -1,23 +0,0 @@ -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.hook - -class MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParams = FeatureLoadParams.INIT_SYNC) { - override fun init() { - val enumQualityLevel = context.mappings.getMappedClass("EnumQualityLevel") - val mediaQualityLevelProvider = context.mappings.getMappedMap("MediaQualityLevelProvider") - - val forceMediaSourceQuality by context.config.global.forceMediaSourceQuality - - context.androidContext.classLoader.loadClass(mediaQualityLevelProvider["class"].toString()).hook( - mediaQualityLevelProvider["method"].toString(), - HookStage.BEFORE, - { forceMediaSourceQuality } - ) { param -> - param.setResult(enumQualityLevel.enumConstants.firstOrNull { it.toString() == "LEVEL_MAX" } ) - } - } -}- \ 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 @@ -1,372 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import android.app.Notification -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.RemoteInput -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.os.Bundle -import android.os.UserHandle -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import me.rhunk.snapenhance.core.Logger -import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType -import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent -import me.rhunk.snapenhance.core.util.CallbackBuilder -import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver -import me.rhunk.snapenhance.core.util.ktx.setObjectField -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -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 -import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID -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.ExperimentalEncodingApi - -class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { - 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<Long, NotificationData>() // messageId => notification - private val cachedMessages = mutableMapOf<String, MutableList<String>>() // conversationId => cached messages - private val notificationIdMap = mutableMapOf<Int, String>() // notificationId => conversationId - - private val notifyAsUserMethod by lazy { - XposedHelpers.findMethodExact( - NotificationManager::class.java, "notifyAsUser", - String::class.java, - Int::class.javaPrimitiveType, - Notification::class.java, - UserHandle::class.java - ) - } - - 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 - } - - private val betterNotificationFilter by lazy { - context.config.messaging.betterNotifications.get() - } - - private fun setNotificationText(notification: Notification, conversationId: String) { - val messageText = StringBuilder().apply { - cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach { - if (isNotEmpty()) append("\n") - append(it) - } - }.toString() - - with(notification.extras) { - putString("android.text", messageText) - putString("android.bigText", messageText) - putParcelableArray("android.messages", messageText.split("\n").map { - Bundle().apply { - putBundle("extras", Bundle()) - putString("text", it) - putLong("time", System.currentTimeMillis()) - } - }.toTypedArray()) - } - } - - private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, messageId: Long, notificationData: NotificationData) { - - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - - val actions = mutableListOf<Notification.Action>() - actions.addAll(notificationData.notification.actions) - - fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { - if (!filter()) return - - val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { - putExtra("conversation_id", conversationId) - putExtra("notification_id", notificationData.id) - putExtra("message_id", messageId) - } - - val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( - context.androidContext, - System.nanoTime().toInt(), - intent, - PendingIntent.FLAG_MUTABLE - )).apply(builder).build() - actions.add(action) - } - - newAction("Reply", ACTION_REPLY, { - betterNotificationFilter.contains("reply_button") && contentType == ContentType.CHAT - }) { - val chatReplyInput = RemoteInput.Builder("chat_reply_input") - .setLabel("Reply") - .build() - it.addRemoteInput(chatReplyInput) - } - - newAction("Download", ACTION_DOWNLOAD, { - betterNotificationFilter.contains("download_button") && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP) - }) {} - - notificationBuilder.setActions(*actions.toTypedArray()) - notificationData.notification = notificationBuilder.build() - } - - private fun setupBroadcastReceiverHook() { - 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 notificationId = intent.getIntExtra("notification_id", -1) - val notificationManager = event.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - 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 - )) - } - } - - when (event.action) { - ACTION_REPLY -> { - val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") - .toString() - - 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") - }) - } - } - ACTION_DOWNLOAD -> { - runCatching { - context.feature(MediaDownloader::class).downloadMessageId(messageId, isPreview = false) - }.onFailure { - context.longToast(it) - } - } - else -> return@subscribe - } - - event.canceled = true - } - } - - private fun fetchMessagesResult(conversationId: String, messages: List<Message>) { - 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 - )) - } - - 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 - } - } - - 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) - - 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()) - ) - - 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) - - runCatching { - val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = { - media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it - }) ?: throw Throwable("Unable to download media") - - val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>() - - MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream -> - downloadedMedias[type] = inputStream.readBytes() - } - - 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)) - } - - val notificationBuilder = XposedHelpers.newInstance( - Notification.Builder::class.java, - context.androidContext, - notificationData.notification - ) as Notification.Builder - notificationBuilder.setLargeIcon(bitmapPreview) - notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) - - sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true) - return@onEach - }.onFailure { - Logger.xposedLog("Failed to send preview notification", it) - } - } - } - else -> { - notificationCache.add(formatUsername("sent ${contentType.name.lowercase()}")) - appendNotifications() - } - } - - sendNotificationData(notificationData, false) - }.clear() - } - } - - override fun init() { - setupBroadcastReceiverHook() - - val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") - - Hooker.hook(notifyAsUserMethod, HookStage.BEFORE) { param -> - val notificationData = NotificationData(param.argNullable(0), param.arg(1), param.arg(2), param.arg(3)) - - 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.map { it.uppercase() }.none { - notificationType.contains(it) - }) return@hook - - val conversationManager: Any = context.feature(Messaging::class).conversationManager - - synchronized(notificationDataQueue) { - notificationDataQueue[messageId.toLong()] = notificationData - } - - val callback = CallbackBuilder(fetchConversationWithMessagesCallback) - .override("onFetchConversationWithMessagesComplete") { callbackParam -> - val messageList = (callbackParam.arg(1) as List<Any>).map { msg -> Message(msg) } - fetchMessagesResult(conversationId, messageList) - } - .override("onError") { - context.log.error("Failed to fetch message ${it.arg(0) as Any}") - }.build() - - fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instanceNonNull(), callback) - param.setResult(null) - } - - XposedHelpers.findMethodExact( - NotificationManager::class.java, - "cancelAsUser", String::class.java, - Int::class.javaPrimitiveType, - UserHandle::class.java - ).hook(HookStage.BEFORE) { param -> - val notificationId = param.arg<Int>(1) - notificationIdMap[notificationId]?.let { - cachedMessages[it]?.clear() - } - } - - findClass("com.google.firebase.messaging.FirebaseMessagingService").run { - 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<Intent>(0) ?: return@hook - val messageType = intent.getStringExtra("type") ?: return@hook - - context.log.debug("received message type: $messageType") - - if (states.contains(messageType.replaceFirst("mischief_", ""))) { - param.setResult(null) - } - } - } - } - - 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/features/impl/tweaks/OldBitmojiSelfie.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/OldBitmojiSelfie.kt @@ -1,22 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent -import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams - -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/features/impl/tweaks/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/SendOverride.kt @@ -1,116 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent -import me.rhunk.snapenhance.core.event.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 - -class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INIT_SYNC) { - private var isLastSnapSavable = false - - override fun init() { - val fixGalleryMediaSendOverride = context.config.experimental.nativeHooks.let { - it.globalState == true && it.fixGalleryMediaOverride.get() - } - val typeNames = mutableListOf( - "ORIGINAL", - "SNAP", - "NOTE" - ).also { - if (fixGalleryMediaSendOverride) { - it.add("SAVABLE_SNAP") - } - }.associateWith { - it - } - - context.event.subscribe(UnaryCallEvent::class, { fixGalleryMediaSendOverride }) { event -> - if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe - if (!isLastSnapSavable) return@subscribe - ProtoReader(event.buffer).also { - // only affect snaps - if (!it.containsPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11)) return@subscribe - } - - event.buffer = ProtoEditor(event.buffer).apply { - //remove the max view time - edit(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH, 11, 5, 2) { - remove(8) - addBuffer(6, byteArrayOf()) - } - //make snaps savable in chat - edit(4) { - val savableState = firstOrNull(7)?.value ?: return@edit - if (savableState == 2L) { - remove(7) - addVarInt(7, 3) - } - } - }.toByteArray() - } - - 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 - - //prevent story replies - val messageProtoReader = ProtoReader(localMessageContent.content) - if (messageProtoReader.contains(7)) return@subscribe - - event.canceled = true - - context.runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) - .setItems(typeNames.values.map { - context.translation["features.options.gallery_media_send_override.$it"] - }.toTypedArray()) { dialog, which -> - dialog.dismiss() - val overrideType = typeNames.keys.toTypedArray()[which] - - if (overrideType != "ORIGINAL" && messageProtoReader.followPath(3)?.getCount(3) != 1) { - context.runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!) - .setMessage(context.translation["gallery_media_send_override.multiple_media_toast"]) - .setPositiveButton(context.translation["button.ok"], null) - .show() - } - return@setItems - } - - when (overrideType) { - "SNAP", "SAVABLE_SNAP" -> { - val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer() - - localMessageContent.contentType = ContentType.SNAP - localMessageContent.content = MessageSender.redSnapProto(extras) - if (overrideType == "SAVABLE_SNAP") { - isLastSnapSavable = true - } - } - - "NOTE" -> { - localMessageContent.contentType = ContentType.NOTE - val mediaDuration = - messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: 0 - localMessageContent.content = - MessageSender.audioNoteProto(mediaDuration) - } - } - - event.invokeOriginal() - } - .setNegativeButton(context.translation["button.cancel"], null) - .show() - } - } - } -}- \ No newline at end of file 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 @@ -1,45 +0,0 @@ -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.Hooker -import me.rhunk.snapenhance.hook.hook - -class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.INIT_SYNC) { - private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) - private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) - - override fun init() { - if (!context.config.global.snapchatPlus.get()) return - - val subscriptionInfoClass = context.mappings.getMappedClass("SubscriptionInfoClass") - - Hooker.hookConstructor(subscriptionInfoClass, HookStage.BEFORE) { param -> - if (param.arg<Int>(0) == 2) return@hookConstructor - //subscription tier - param.setArg(0, 2) - //subscription status - param.setArg(1, 2) - - 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<Any>() - val firstArg = param.arg<Any>(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 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 @@ -1,32 +0,0 @@ -package me.rhunk.snapenhance.features.impl.tweaks - -import me.rhunk.snapenhance.core.event.events.impl.BuildMessageEvent -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.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams - -class UnlimitedSnapViewTime : - Feature("UnlimitedSnapViewTime", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - override fun onActivityCreate() { - val state by context.config.messaging.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 - - val messageContent = event.message.messageContent - - val mediaAttributes = ProtoReader(messageContent.content).followPath(11, 5, 2) ?: return@subscribe - if (mediaAttributes.contains(6)) return@subscribe - messageContent.content = ProtoEditor(messageContent.content).apply { - edit(11, 5, 2) { - remove(8) - addBuffer(6, byteArrayOf()) - } - }.toByteArray() - } - } -} 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 @@ -1,34 +0,0 @@ -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/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/FriendFeedMessagePreview.kt @@ -1,106 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -import android.annotation.SuppressLint -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Rect -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.shapes.Shape -import android.text.TextPaint -import android.view.View -import android.view.ViewGroup -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.ui.addForegroundDrawable -import me.rhunk.snapenhance.ui.removeForegroundDrawable -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)) - ).getColor(0, 0) - } - - 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 - - val ffItemId = context.resources.getIdentifier("ff_item", "id", Constants.SNAPCHAT_PACKAGE_NAME) - - 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 feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + ffSdlAvatarStartMargin - val separatorHeight = (context.resources.displayMetrics.density * 2).toInt() - val textPaint = TextPaint().apply { - textSize = secondaryTextSize - } - - context.event.subscribe(BindViewEvent::class) { param -> - param.friendFeedItem { conversationId -> - val frameLayout = param.view as ViewGroup - val ffItem = frameLayout.findViewById<View>(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) - ?: return@mapNotNull null - - val messageString = messageContainer.getString(2, 1) - ?: ContentType.fromMessageContainer(messageContainer)?.name - ?: return@mapNotNull null - - val friendName = context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" - - "$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 - } - - 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 - ) - } - } - })) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/HideStreakRestore.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/HideStreakRestore.kt @@ -1,19 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -import me.rhunk.snapenhance.core.util.ktx.getObjectField -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 - -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<Any>().getObjectField("mStreakMetadata") ?: return@hookConstructor - streakMetadata.setObjectField("mExpiredStreak", null) - } - } -}- \ No newline at end of file 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 @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -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.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 : 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<Any>(1).toString() == "PINNED" - setState(conversationUUID.toString(), isPinned) - } - - context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> - val instance = param.thisObject<Any>() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) - if (getState(conversationUUID.toString())) { - instance.setObjectField("mPinnedTimestampMs", 1L) - } - } - - context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> - val instance = param.thisObject<Any>() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) - val isPinned = getState(conversationUUID.toString()) - if (isPinned) { - instance.setObjectField("mPinnedTimestampMs", 1L) - } - } - } - - override fun getRuleState() = RuleState.WHITELIST -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt @@ -1,150 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Resources -import android.graphics.Rect -import android.text.SpannableString -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.widget.FrameLayout -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.event.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.Hooker -import me.rhunk.snapenhance.hook.hook - -class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { - private val identifierCache = mutableMapOf<String, Int>() - - @SuppressLint("DiscouragedApi") - fun getIdentifier(name: String, defType: String): Int { - return identifierCache.getOrPut("$name:$defType") { - context.resources.getIdentifier(name, defType, Constants.SNAPCHAT_PACKAGE_NAME) - } - } - - private fun hideStorySection(event: AddViewEvent) { - val parent = event.parent - parent.visibility = View.GONE - val marginLayoutParams = parent.layoutParams as MarginLayoutParams - marginLayoutParams.setMargins(-99999, -99999, -99999, -99999) - event.canceled = true - } - - private var surfaceViewAspectRatio: Float = 0f - - @SuppressLint("DiscouragedApi", "InternalInsetResource") - override fun onActivityCreate() { - val blockAds by context.config.global.blockAds - val hiddenElements by context.config.userInterface.hideUiComponents - val hideStorySections by context.config.userInterface.hideStorySections - val isImmersiveCamera by context.config.camera.immersiveCameraPreview - - 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 chatNoteRecordButton = getIdentifier("chat_note_record_button", "id") - - View::class.java.hook("setVisibility", HookStage.BEFORE) { methodParam -> - val viewId = (methodParam.thisObject() as View).id - if (viewId == callButton1 || viewId == callButton2) { - if (!hiddenElements.contains("hide_profile_call_buttons")) return@hook - methodParam.setArg(0, View.GONE) - } - } - - Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook(HookStage.AFTER, - { isImmersiveCamera } - ) { param -> - val id = param.arg<Int>(0) - if (id == getIdentifier("capri_viewfinder_default_corner_radius", "dimen") || - id == getIdentifier("ngs_hova_nav_larger_camera_button_size", "dimen")) { - param.setResult(0) - } - } - - context.event.subscribe(AddViewEvent::class) { event -> - val viewId = event.view.id - val view = event.view - - if (hideStorySections.contains("hide_for_you")) { - if (viewId == getIdentifier("df_large_story", "id") || - viewId == getIdentifier("df_promoted_story", "id")) { - hideStorySection(event) - return@subscribe - } - if (viewId == getIdentifier("stories_load_progress_layout", "id")) { - event.canceled = true - } - } - - if (hideStorySections.contains("hide_friends") && viewId == getIdentifier("friend_card_frame", "id")) { - hideStorySection(event) - } - - //mappings? - if (hideStorySections.contains("hide_friend_suggestions") && view.javaClass.superclass?.name?.endsWith("StackDrawLayout") == true) { - val layoutParams = view.layoutParams as? FrameLayout.LayoutParams ?: return@subscribe - if (layoutParams.width == -1 && - layoutParams.height == -2 && - view.javaClass.let { clazz -> - clazz.methods.any { it.returnType == SpannableString::class.java} && - clazz.constructors.any { it.parameterCount == 1 && it.parameterTypes[0] == Context::class.java } - } - ) { - hideStorySection(event) - } - } - - if (hideStorySections.contains("hide_suggested") && (viewId == getIdentifier("df_small_story", "id")) - ) { - hideStorySection(event) - } - - if (blockAds && viewId == getIdentifier("df_promoted_story", "id")) { - hideStorySection(event) - } - - if (isImmersiveCamera) { - if (view.id == getIdentifier("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")) { - Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { - it.setArg(1, 1) - it.setArg(3, displayMetrics.heightPixels) - } - } - } - - 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 == callButtonsStub && hiddenElements.contains("hide_chat_call_buttons")) - ) { - view.apply { - view.post { - isEnabled = false - setWillNotDraw(true) - view.visibility = View.GONE - } - addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - view.post { view.visibility = View.GONE } - } - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt @@ -1,76 +0,0 @@ -package me.rhunk.snapenhance.hook - -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import java.lang.reflect.Member -import java.util.function.Consumer - -@Suppress("UNCHECKED_CAST") -class HookAdapter( - private val methodHookParam: XC_MethodHook.MethodHookParam<*> -) { - fun <T : Any> thisObject(): T { - return methodHookParam.thisObject as T - } - - fun <T : Any> nullableThisObject(): T? { - return methodHookParam.thisObject as T? - } - - fun method(): Member { - return methodHookParam.method - } - - fun <T : Any> arg(index: Int): T { - return methodHookParam.args[index] as T - } - - fun <T : Any> argNullable(index: Int): T? { - return methodHookParam.args.getOrNull(index) as T? - } - - fun setArg(index: Int, value: Any?) { - if (index < 0 || index >= methodHookParam.args.size) return - methodHookParam.args[index] = value - } - - fun args(): Array<Any?> { - return methodHookParam.args - } - - fun getResult(): Any? { - return methodHookParam.result - } - - fun setResult(result: Any?) { - methodHookParam.result = result - } - - fun setThrowable(throwable: Throwable) { - methodHookParam.throwable = throwable - } - - fun throwable(): Throwable? { - return methodHookParam.throwable - } - - fun invokeOriginal(): Any? { - return XposedBridge.invokeOriginalMethod(method(), thisObject(), args()) - } - - fun invokeOriginal(args: Array<Any>): Any? { - return XposedBridge.invokeOriginalMethod(method(), thisObject(), args) - } - - fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) { - invokeOriginalSafe(args(), errorCallback) - } - - fun invokeOriginalSafe(args: Array<Any?>, errorCallback: Consumer<Throwable>) { - runCatching { - setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args)) - }.onFailure { - errorCallback.accept(it) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt b/core/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt @@ -1,6 +0,0 @@ -package me.rhunk.snapenhance.hook - -enum class HookStage { - BEFORE, - AFTER -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt @@ -1,157 +0,0 @@ -package me.rhunk.snapenhance.hook - -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import java.lang.reflect.Member -import java.lang.reflect.Method - -object Hooker { - inline fun newMethodHook( - stage: HookStage, - crossinline consumer: (HookAdapter) -> Unit, - crossinline filter: ((HookAdapter) -> Boolean) = { true } - ): XC_MethodHook { - return if (stage == HookStage.BEFORE) object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam<*>) { - HookAdapter(param).takeIf(filter)?.also(consumer) - } - } else object : XC_MethodHook() { - override fun afterHookedMethod(param: MethodHookParam<*>) { - HookAdapter(param).takeIf(filter)?.also(consumer) - } - } - } - - inline fun hook( - clazz: Class<*>, - methodName: String, - stage: HookStage, - crossinline filter: (HookAdapter) -> Boolean, - noinline consumer: (HookAdapter) -> Unit - ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) - - inline fun hook( - member: Member, - stage: HookStage, - crossinline filter: ((HookAdapter) -> Boolean), - crossinline consumer: (HookAdapter) -> Unit - ): XC_MethodHook.Unhook { - return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) - } - - fun hook( - clazz: Class<*>, - methodName: String, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer) - - fun hook( - member: Member, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ): XC_MethodHook.Unhook { - return hook(member, stage, { true }, consumer) - } - - fun hookConstructor( - clazz: Class<*>, - stage: HookStage, - consumer: (HookAdapter) -> Unit - ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) - - fun hookConstructor( - clazz: Class<*>, - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit - ) { - XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) - } - - inline fun hookObjectMethod( - clazz: Class<*>, - instance: Any, - methodName: String, - stage: HookStage, - crossinline hookConsumer: (HookAdapter) -> Unit - ) { - val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() - hook(clazz, methodName, stage) { param-> - if (param.nullableThisObject<Any>().let { - if (it == null) unhooks.forEach { u -> u.unhook() } - it != instance - }) return@hook - hookConsumer(param) - }.also { unhooks.addAll(it) } - } - - inline fun ephemeralHook( - clazz: Class<*>, - methodName: String, - stage: HookStage, - crossinline hookConsumer: (HookAdapter) -> Unit - ) { - val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() - hook(clazz, methodName, stage) { param-> - hookConsumer(param) - unhooks.forEach{ it.unhook() } - }.also { unhooks.addAll(it) } - } - - inline fun ephemeralHookObjectMethod( - clazz: Class<*>, - instance: Any, - methodName: String, - stage: HookStage, - crossinline hookConsumer: (HookAdapter) -> Unit - ) { - val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() - hook(clazz, methodName, stage) { param-> - if (param.nullableThisObject<Any>() != instance) return@hook - unhooks.forEach { it.unhook() } - hookConsumer(param) - }.also { unhooks.addAll(it) } - } -} - -fun Class<*>.hookConstructor( - stage: HookStage, - consumer: (HookAdapter) -> Unit -) = Hooker.hookConstructor(this, stage, consumer) - -fun Class<*>.hookConstructor( - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit -) = Hooker.hookConstructor(this, stage, filter, consumer) - -fun Class<*>.hook( - methodName: String, - stage: HookStage, - consumer: (HookAdapter) -> Unit -): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer) - -fun Class<*>.hook( - methodName: String, - stage: HookStage, - filter: (HookAdapter) -> Boolean, - consumer: (HookAdapter) -> Unit -): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer) - -fun Member.hook( - stage: HookStage, - consumer: (HookAdapter) -> Unit -): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer) - -fun Member.hook( - stage: HookStage, - filter: ((HookAdapter) -> Boolean), - consumer: (HookAdapter) -> Unit -): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer) - -fun Array<Method>.hookAll(stage: HookStage, param: (HookAdapter) -> Unit) { - filter { it.declaringClass != Object::class.java }.forEach { - it.hook(stage, param) - } -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt @@ -1,6 +0,0 @@ -package me.rhunk.snapenhance.manager - -interface Manager { - fun init() {} - fun onActivityCreate() {} -}- \ No newline at end of file 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 @@ -1,37 +0,0 @@ -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.EnumAction -import me.rhunk.snapenhance.manager.Manager - -class ActionManager( - private val modContext: ModContext, -) : Manager { - companion object { - const val ACTION_PARAMETER = "se_action" - } - private val actions = mutableMapOf<String, AbstractAction>() - - override fun init() { - EnumAction.entries.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.entries.find { it.key == action } ?: return) - intent.removeExtra(ACTION_PARAMETER) - } - - 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/manager/impl/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -1,171 +0,0 @@ -package me.rhunk.snapenhance.manager.impl - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -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.MessagingRuleFeature -import me.rhunk.snapenhance.features.impl.ConfigurationOverride -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.ScopeSync -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.downloader.ProfilePictureDownloader -import me.rhunk.snapenhance.features.impl.experiments.* -import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics -import me.rhunk.snapenhance.features.impl.privacy.PreventMessageSending -import me.rhunk.snapenhance.features.impl.spying.AnonymousStoryViewing -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts -import me.rhunk.snapenhance.features.impl.spying.SnapToChatMedia -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.features.impl.tweaks.* -import me.rhunk.snapenhance.features.impl.ui.ClientBootstrapOverride -import me.rhunk.snapenhance.features.impl.ui.FriendFeedMessagePreview -import me.rhunk.snapenhance.features.impl.ui.HideStreakRestore -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 -import kotlin.reflect.KClass -import kotlin.system.measureTimeMillis - -class FeatureManager( - private val context: ModContext -) : Manager { - private val features = mutableListOf<Feature>() - - private fun register(vararg featureClasses: KClass<out Feature>) { - runBlocking { - featureClasses.forEach { clazz -> - launch(Dispatchers.IO) { - runCatching { - clazz.java.constructors.first().newInstance() - .let { it as Feature } - .also { - it.context = context - synchronized(features) { - features.add(it) - } - } - }.onFailure { - Logger.xposedLog("Failed to register feature ${clazz.simpleName}", it) - } - } - } - } - } - - @Suppress("UNCHECKED_CAST") - fun <T : Feature> get(featureClass: KClass<T>): T? { - return features.find { it::class == featureClass } as? T - } - - fun getRuleFeatures() = features.filterIsInstance<MessagingRuleFeature>() - - override fun init() { - register( - EndToEndEncryption::class, - ScopeSync::class, - Messaging::class, - MediaDownloader::class, - StealthMode::class, - MenuViewInjector::class, - PreventReadReceipts::class, - AnonymousStoryViewing::class, - MessageLogger::class, - SnapchatPlus::class, - DisableMetrics::class, - PreventMessageSending::class, - Notifications::class, - AutoSave::class, - UITweaks::class, - ConfigurationOverride::class, - SendOverride::class, - UnlimitedSnapViewTime::class, - BypassVideoLengthRestriction::class, - MediaQualityLevelOverride::class, - MeoPasscodeBypass::class, - AppPasscode::class, - LocationSpoofer::class, - CameraTweaks::class, - InfiniteStoryBoost::class, - AmoledDarkMode::class, - PinConversations::class, - UnlimitedMultiSnap::class, - DeviceSpooferHook::class, - ClientBootstrapOverride::class, - GooglePlayServicesDialogs::class, - NoFriendScoreDelay::class, - ProfilePictureDownloader::class, - AddFriendSourceSpoof::class, - DisableReplayInFF::class, - OldBitmojiSelfie::class, - SnapToChatMedia::class, - FriendFeedMessagePreview::class, - HideStreakRestore::class, - ) - - initializeFeatures() - } - - private fun initFeatures( - syncParam: Int, - asyncParam: Int, - syncAction: (Feature) -> Unit, - asyncAction: (Feature) -> Unit - ) { - fun tryInit(feature: Feature, block: () -> Unit) { - runCatching { - block() - }.onFailure { - context.log.error("Failed to init feature ${feature.featureKey}", it) - context.longToast("Failed to init feature ${feature.featureKey}! Check logcat for more details.") - } - } - - features.toList().forEach { feature -> - if (feature.loadParams and syncParam != 0) { - tryInit(feature) { - syncAction(feature) - } - } - if (feature.loadParams and asyncParam != 0) { - context.coroutineScope.launch { - tryInit(feature) { - asyncAction(feature) - } - } - } - } - } - - private fun initializeFeatures() { - //TODO: async called when all features are initiated ? - measureTimeMillis { - initFeatures( - FeatureLoadParams.INIT_SYNC, - FeatureLoadParams.INIT_ASYNC, - Feature::init, - Feature::asyncInit - ) - }.also { - context.log.verbose("feature manager init took $it ms") - } - } - - override fun onActivityCreate() { - measureTimeMillis { - initFeatures( - FeatureLoadParams.ACTIVITY_CREATE_SYNC, - FeatureLoadParams.ACTIVITY_CREATE_ASYNC, - Feature::onActivityCreate, - Feature::asyncOnActivityCreate - ) - }.also { - context.log.verbose("feature manager onActivityCreate took $it ms") - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/IPCInterface.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/IPCInterface.kt @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.scripting - -typealias Listener = (Array<out String?>) -> Unit - -abstract class IPCInterface { - abstract fun on(eventName: String, listener: Listener) - - abstract fun onBroadcast(channel: String, eventName: String, listener: Listener) - - abstract fun emit(eventName: String, vararg args: String?) - abstract fun broadcast(channel: String, eventName: String, vararg args: String?) - - @Suppress("unused") - fun emit(eventName: String) = emit(eventName, *emptyArray()) - @Suppress("unused") - fun emit(channel: String, eventName: String) = broadcast(channel, eventName) -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/JSModule.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/JSModule.kt @@ -1,134 +0,0 @@ -package me.rhunk.snapenhance.scripting - -import android.os.Handler -import android.widget.Toast -import me.rhunk.snapenhance.scripting.ktx.contextScope -import me.rhunk.snapenhance.scripting.ktx.putFunction -import me.rhunk.snapenhance.scripting.ktx.scriptableObject -import me.rhunk.snapenhance.scripting.type.ModuleInfo -import org.mozilla.javascript.Function -import org.mozilla.javascript.NativeJavaObject -import org.mozilla.javascript.ScriptableObject -import org.mozilla.javascript.Undefined -import org.mozilla.javascript.Wrapper -import java.lang.reflect.Modifier - -class JSModule( - val scriptRuntime: ScriptRuntime, - val moduleInfo: ModuleInfo, - val content: String, -) { - private lateinit var moduleObject: ScriptableObject - - fun load(block: ScriptableObject.() -> Unit) { - contextScope { - val classLoader = scriptRuntime.androidContext.classLoader - moduleObject = initSafeStandardObjects() - moduleObject.putConst("module", moduleObject, scriptableObject { - putConst("info", this, scriptableObject { - putConst("name", this, moduleInfo.name) - putConst("version", this, moduleInfo.version) - putConst("description", this, moduleInfo.description) - putConst("author", this, moduleInfo.author) - putConst("minSnapchatVersion", this, moduleInfo.minSnapchatVersion) - putConst("minSEVersion", this, moduleInfo.minSEVersion) - putConst("grantPermissions", this, moduleInfo.grantPermissions) - }) - }) - - moduleObject.putFunction("setField") { args -> - val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance - val name = args[1].toString() - val value = args[2] - val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance - field.isAccessible = true - field.set(obj.unwrap(), value.toPrimitiveValue(lazy { field.type.name })) - Undefined.instance - } - - moduleObject.putFunction("getField") { args -> - val obj = args?.get(0) as? NativeJavaObject ?: return@putFunction Undefined.instance - val name = args[1].toString() - val field = obj.unwrap().javaClass.declaredFields.find { it.name == name } ?: return@putFunction Undefined.instance - field.isAccessible = true - field.get(obj.unwrap()) - } - - moduleObject.putFunction("findClass") { - val className = it?.get(0).toString() - classLoader.loadClass(className) - } - - moduleObject.putFunction("type") { args -> - val className = args?.get(0).toString() - val clazz = classLoader.loadClass(className) - - scriptableObject("JavaClassWrapper") { - putFunction("newInstance") newInstance@{ args -> - val constructor = clazz.declaredConstructors.find { - it.parameterCount == (args?.size ?: 0) - } ?: return@newInstance Undefined.instance - constructor.newInstance(*args ?: emptyArray()) - } - - clazz.declaredMethods.filter { Modifier.isStatic(it.modifiers) }.forEach { method -> - putFunction(method.name) { args -> - clazz.declaredMethods.find { - it.name == method.name && it.parameterTypes.zip(args ?: emptyArray()).all { (type, arg) -> - type.isAssignableFrom(arg.javaClass) - } - }?.invoke(null, *args ?: emptyArray()) - } - } - - clazz.declaredFields.filter { Modifier.isStatic(it.modifiers) }.forEach { field -> - field.isAccessible = true - defineProperty(field.name, { field.get(null)}, { value -> field.set(null, value) }, 0) - } - } - } - - moduleObject.putFunction("logInfo") { args -> - scriptRuntime.logger.info(args?.joinToString(" ") { - when (it) { - is Wrapper -> it.unwrap().toString() - else -> it.toString() - } - } ?: "null") - Undefined.instance - } - - for (toastFunc in listOf("longToast", "shortToast")) { - moduleObject.putFunction(toastFunc) { args -> - Handler(scriptRuntime.androidContext.mainLooper).post { - Toast.makeText( - scriptRuntime.androidContext, - args?.joinToString(" ") ?: "", - if (toastFunc == "longToast") Toast.LENGTH_LONG else Toast.LENGTH_SHORT - ).show() - } - Undefined.instance - } - } - - block(moduleObject) - evaluateString(moduleObject, content, moduleInfo.name, 1, null) - } - } - - fun unload() { - callFunction("module.onUnload") - } - - fun callFunction(name: String, vararg args: Any?) { - contextScope { - name.split(".").also { split -> - val function = split.dropLast(1).fold(moduleObject) { obj, key -> - obj.get(key, obj) as? ScriptableObject ?: return@contextScope - }.get(split.last(), moduleObject) as? Function ?: return@contextScope - - function.call(this, moduleObject, moduleObject, args) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/PrimitiveUtil.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/PrimitiveUtil.kt @@ -1,17 +0,0 @@ -package me.rhunk.snapenhance.scripting - -fun Any?.toPrimitiveValue(type: Lazy<String>) = when (this) { - is Number -> when (type.value) { - "byte" -> this.toByte() - "short" -> this.toShort() - "int" -> this.toInt() - "long" -> this.toLong() - "float" -> this.toFloat() - "double" -> this.toDouble() - "boolean" -> this.toByte() != 0.toByte() - "char" -> this.toInt().toChar() - else -> this - } - is Boolean -> if (type.value == "boolean") this.toString().toBoolean() else this - else -> this -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ScriptRuntime.kt @@ -1,90 +0,0 @@ -package me.rhunk.snapenhance.scripting - -import android.content.Context -import me.rhunk.snapenhance.core.logger.AbstractLogger -import me.rhunk.snapenhance.scripting.type.ModuleInfo -import org.mozilla.javascript.ScriptableObject -import java.io.BufferedReader -import java.io.ByteArrayInputStream -import java.io.InputStream - -open class ScriptRuntime( - val androidContext: Context, - val logger: AbstractLogger, -) { - var buildModuleObject: ScriptableObject.(JSModule) -> Unit = {} - private val modules = mutableMapOf<String, JSModule>() - - fun eachModule(f: JSModule.() -> Unit) { - modules.values.forEach { module -> - runCatching { - module.f() - }.onFailure { - logger.error("Failed to run module function in ${module.moduleInfo.name}", it) - } - } - } - - private fun readModuleInfo(reader: BufferedReader): ModuleInfo { - val header = reader.readLine() - if (!header.startsWith("// ==SE_module==")) { - throw Exception("Invalid module header") - } - - val properties = mutableMapOf<String, String>() - while (true) { - val line = reader.readLine() - if (line.startsWith("// ==/SE_module==")) { - break - } - val split = line.replaceFirst("//", "").split(":") - if (split.size != 2) { - throw Exception("Invalid module property") - } - properties[split[0].trim()] = split[1].trim() - } - - return ModuleInfo( - name = properties["name"] ?: throw Exception("Missing module name"), - version = properties["version"] ?: throw Exception("Missing module version"), - description = properties["description"], - author = properties["author"], - minSnapchatVersion = properties["minSnapchatVersion"]?.toLong(), - minSEVersion = properties["minSEVersion"]?.toLong(), - grantPermissions = properties["permissions"]?.split(",")?.map { it.trim() }, - ) - } - - fun getModuleInfo(inputStream: InputStream): ModuleInfo { - return readModuleInfo(inputStream.bufferedReader()) - } - - fun reload(path: String, content: String) { - unload(path) - load(path, content) - } - - private fun unload(path: String) { - val module = modules[path] ?: return - module.unload() - modules.remove(path) - } - - fun load(path: String, content: String): JSModule? { - logger.info("Loading module $path") - return runCatching { - JSModule( - scriptRuntime = this, - moduleInfo = readModuleInfo(ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)).bufferedReader()), - content = content, - ).apply { - load { - buildModuleObject(this, this@apply) - } - modules[path] = this - } - }.onFailure { - logger.error("Failed to load module $path", it) - }.getOrNull() - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/CoreScriptRuntime.kt @@ -1,56 +0,0 @@ -package me.rhunk.snapenhance.scripting.core - -import android.content.Context -import me.rhunk.snapenhance.bridge.scripting.IPCListener -import me.rhunk.snapenhance.bridge.scripting.IScripting -import me.rhunk.snapenhance.core.logger.AbstractLogger -import me.rhunk.snapenhance.scripting.IPCInterface -import me.rhunk.snapenhance.scripting.Listener -import me.rhunk.snapenhance.scripting.ScriptRuntime -import me.rhunk.snapenhance.scripting.core.impl.ScriptHooker - -class CoreScriptRuntime( - androidContext: Context, - logger: AbstractLogger, -): ScriptRuntime(androidContext, logger) { - private val scriptHookers = mutableListOf<ScriptHooker>() - - fun connect(scriptingInterface: IScripting) { - scriptingInterface.apply { - buildModuleObject = { module -> - putConst("ipc", this, object: IPCInterface() { - override fun onBroadcast(channel: String, eventName: String, listener: Listener) { - registerIPCListener(channel, eventName, object: IPCListener.Stub() { - override fun onMessage(args: Array<out String?>) { - listener(args) - } - }) - } - - override fun on(eventName: String, listener: Listener) { - onBroadcast(module.moduleInfo.name, eventName, listener) - } - - override fun emit(eventName: String, vararg args: String?) { - broadcast(module.moduleInfo.name, eventName, *args) - } - - override fun broadcast(channel: String, eventName: String, vararg args: String?) { - sendIPCMessage(channel, eventName, args) - } - }) - putConst("hooker", this, ScriptHooker(module.moduleInfo, logger, androidContext.classLoader).also { - scriptHookers.add(it) - }) - } - } - - scriptingInterface.enabledScripts.forEach { path -> - runCatching { - load(path, scriptingInterface.getScriptContent(path)) - }.onFailure { - logger.error("Failed to load script $path", it) - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/core/impl/ScriptHooker.kt @@ -1,161 +0,0 @@ -package me.rhunk.snapenhance.scripting.core.impl - -import me.rhunk.snapenhance.core.logger.AbstractLogger -import me.rhunk.snapenhance.hook.HookAdapter -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.hook.hook -import me.rhunk.snapenhance.hook.hookConstructor -import me.rhunk.snapenhance.scripting.toPrimitiveValue -import me.rhunk.snapenhance.scripting.type.ModuleInfo -import org.mozilla.javascript.annotations.JSGetter -import org.mozilla.javascript.annotations.JSSetter -import java.lang.reflect.Constructor -import java.lang.reflect.Member -import java.lang.reflect.Method - - -class ScriptHookCallback( - private val hookAdapter: HookAdapter -) { - var result - @JSGetter("result") get() = hookAdapter.getResult() - @JSSetter("result") set(result) = hookAdapter.setResult(result.toPrimitiveValue(lazy { - when (val member = hookAdapter.method()) { - is Method -> member.returnType.name - else -> "void" - } - })) - - val thisObject - @JSGetter("thisObject") get() = hookAdapter.nullableThisObject<Any>() - - val method - @JSGetter("method") get() = hookAdapter.method() - - val args - @JSGetter("args") get() = hookAdapter.args().toList() - - private val parameterTypes by lazy { - when (val member = hookAdapter.method()) { - is Method -> member.parameterTypes - is Constructor<*> -> member.parameterTypes - else -> emptyArray() - }.toList() - } - - fun cancel() = hookAdapter.setResult(null) - - fun arg(index: Int) = hookAdapter.argNullable<Any>(index) - - fun setArg(index: Int, value: Any) { - hookAdapter.setArg(index, value.toPrimitiveValue(lazy { parameterTypes[index].name })) - } - - fun invokeOriginal() = hookAdapter.invokeOriginal() - - fun invokeOriginal(args: Array<Any>) = hookAdapter.invokeOriginal(args.map { - it.toPrimitiveValue(lazy { parameterTypes[args.indexOf(it)].name }) ?: it - }.toTypedArray()) - - override fun toString(): String { - return "ScriptHookCallback(\n" + - " thisObject=${ runCatching { thisObject.toString() }.getOrNull() },\n" + - " args=${ runCatching { args.toString() }.getOrNull() }\n" + - " result=${ runCatching { result.toString() }.getOrNull() },\n" + - ")" - } -} - - -typealias HookCallback = (ScriptHookCallback) -> Unit -typealias HookUnhook = () -> Unit - -@Suppress("unused", "MemberVisibilityCanBePrivate") -class ScriptHooker( - private val moduleInfo: ModuleInfo, - private val logger: AbstractLogger, - private val classLoader: ClassLoader -) { - private val hooks = mutableListOf<HookUnhook>() - - // -- search for class members - - private fun findClassSafe(className: String): Class<*>? { - return runCatching { - classLoader.loadClass(className) - }.onFailure { - logger.warn("Failed to load class $className") - }.getOrNull() - } - - private fun getHookStageFromString(stage: String): HookStage { - return when (stage) { - "before" -> HookStage.BEFORE - "after" -> HookStage.AFTER - else -> throw IllegalArgumentException("Invalid stage: $stage") - } - } - - fun findMethod(clazz: Class<*>, methodName: String): Member? { - return clazz.declaredMethods.find { it.name == methodName } - } - - fun findMethodWithParameters(clazz: Class<*>, methodName: String, vararg types: String): Member? { - return clazz.declaredMethods.find { method -> method.name == methodName && method.parameterTypes.map { it.name }.toTypedArray() contentEquals types } - } - - fun findMethod(className: String, methodName: String): Member? { - return findClassSafe(className)?.let { findMethod(it, methodName) } - } - - fun findMethodWithParameters(className: String, methodName: String, vararg types: String): Member? { - return findClassSafe(className)?.let { findMethodWithParameters(it, methodName, *types) } - } - - fun findConstructor(clazz: Class<*>, vararg types: String): Member? { - return clazz.declaredConstructors.find { constructor -> constructor.parameterTypes.map { it.name }.toTypedArray() contentEquals types } - } - - fun findConstructorParameters(className: String, vararg types: String): Member? { - return findClassSafe(className)?.let { findConstructor(it, *types) } - } - - // -- hooking - - fun hook(method: Member, stage: String, callback: HookCallback): HookUnhook { - val hookAdapter = Hooker.hook(method, getHookStageFromString(stage)) { - callback(ScriptHookCallback(it)) - } - - return { - hookAdapter.unhook() - }.also { hooks.add(it) } - } - - fun hookAllMethods(clazz: Class<*>, methodName: String, stage: String, callback: HookCallback): HookUnhook { - val hookAdapter = clazz.hook(methodName, getHookStageFromString(stage)) { - callback(ScriptHookCallback(it)) - } - - return { - hookAdapter.forEach { it.unhook() } - }.also { hooks.add(it) } - } - - fun hookAllConstructors(clazz: Class<*>, stage: String, callback: HookCallback): HookUnhook { - val hookAdapter = clazz.hookConstructor(getHookStageFromString(stage)) { - callback(ScriptHookCallback(it)) - } - - return { - hookAdapter.forEach { it.unhook() } - }.also { hooks.add(it) } - } - - fun hookAllMethods(className: String, methodName: String, stage: String, callback: HookCallback) - = findClassSafe(className)?.let { hookAllMethods(it, methodName, stage, callback) } - - fun hookAllConstructors(className: String, stage: String, callback: HookCallback) - = findClassSafe(className)?.let { hookAllConstructors(it, stage, callback) } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/ktx/RhinoKtx.kt @@ -1,43 +0,0 @@ -package me.rhunk.snapenhance.scripting.ktx - -import org.mozilla.javascript.Context -import org.mozilla.javascript.Function -import org.mozilla.javascript.Scriptable -import org.mozilla.javascript.ScriptableObject - -fun contextScope(f: Context.() -> Unit) { - val context = Context.enter() - context.optimizationLevel = -1 - try { - context.f() - } finally { - Context.exit() - } -} - -fun Scriptable.scriptable(name: String): Scriptable? { - return this.get(name, this) as? Scriptable -} - -fun Scriptable.function(name: String): Function? { - return this.get(name, this) as? Function -} - -fun ScriptableObject.putFunction(name: String, proxy: Scriptable.(Array<out Any>?) -> Any?) { - this.putConst(name, this, object: org.mozilla.javascript.BaseFunction() { - override fun call( - cx: Context?, - scope: Scriptable, - thisObj: Scriptable, - args: Array<out Any>? - ): Any? { - return thisObj.proxy(args) - } - }) -} - -fun scriptableObject(name: String? = "ScriptableObject", f: ScriptableObject.() -> Unit): ScriptableObject { - return object: ScriptableObject() { - override fun getClassName() = name - }.apply(f) -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/scripting/type/ModuleInfo.kt b/core/src/main/kotlin/me/rhunk/snapenhance/scripting/type/ModuleInfo.kt @@ -1,11 +0,0 @@ -package me.rhunk.snapenhance.scripting.type - -data class ModuleInfo( - val name: String, - val version: String, - val description: String? = null, - val author: String? = null, - val minSnapchatVersion: Long? = null, - val minSEVersion: Long? = null, - val grantPermissions: List<String>? = null, -)- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewAppearanceHelper.kt @@ -1,141 +0,0 @@ -package me.rhunk.snapenhance.ui - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.StateListDrawable -import android.graphics.drawable.shapes.Shape -import android.view.Gravity -import android.view.View -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.Constants -import kotlin.random.Random - -fun View.applyTheme(componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { - ViewAppearanceHelper.applyTheme(this, componentWidth, hasRadius, isAmoled) -} - -private val foregroundDrawableListTag = Random.nextInt(0x7000000, 0x7FFFFFFF) - -@Suppress("UNCHECKED_CAST") -private fun View.getForegroundDrawables(): MutableMap<String, Drawable> { - return getTag(foregroundDrawableListTag) as? MutableMap<String, Drawable> - ?: mutableMapOf<String, Drawable>().also { - setTag(foregroundDrawableListTag, it) - } -} - -private fun View.updateForegroundDrawable() { - foreground = ShapeDrawable(object: Shape() { - override fun draw(canvas: Canvas, paint: Paint) { - getForegroundDrawables().forEach { (_, drawable) -> - drawable.draw(canvas) - } - } - }) -} - -fun View.removeForegroundDrawable(tag: String) { - getForegroundDrawables().remove(tag)?.let { - updateForegroundDrawable() - } -} - -fun View.addForegroundDrawable(tag: String, drawable: Drawable) { - getForegroundDrawables()[tag] = drawable - updateForegroundDrawable() -} - - -object ViewAppearanceHelper { - @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", - "ClickableViewAccessibility" - ) - private var sigColorTextPrimary: Int = 0 - private var sigColorBackgroundSurface: Int = 0 - - private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { - if (!hasRadius) return ColorDrawable(color) - //FIXME: hardcoded radius - return ShapeDrawable().apply { - paint.color = color - shape = android.graphics.drawable.shapes.RoundRectShape( - floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), - null, - null - ) - } - } - - @SuppressLint("DiscouragedApi") - fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { - val resources = component.context.resources - if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { - with(component.context.theme) { - sigColorTextPrimary = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - - sigColorBackgroundSurface = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - } - } - - val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", Constants.SNAPCHAT_PACKAGE_NAME) - val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 - - with(component) { - if (this is TextView) { - setTextColor(sigColorTextPrimary) - setShadowLayer(0F, 0F, 0F, 0) - gravity = Gravity.CENTER_VERTICAL - componentWidth?.let { width = it} - height = (150 * scalingFactor).toInt() - isAllCaps = false - textSize = 16f - typeface = resources.getFont(snapchatFontResId) - outlineProvider = null - setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) - } - 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) - } - } - - if (component is Switch) { - with(resources) { - component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() - } - component.trackTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#1d1d1d"), - Color.parseColor("#26bd49") - ) - ) - component.thumbTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#F5F5F5"), - Color.parseColor("#26bd49") - ) - ) - } - } - - fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert) -} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewTagState.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/ViewTagState.kt @@ -1,20 +0,0 @@ -package me.rhunk.snapenhance.ui - -import android.view.View -import kotlin.random.Random - -class ViewTagState { - private val tag = Random.nextInt(0x7000000, 0x7FFFFFFF) - - operator fun get(view: View) = hasState(view) - - private fun hasState(view: View): Boolean { - if (view.getTag(tag) != null) return true - view.setTag(tag, true) - return false - } - - fun removeState(view: View) { - view.setTag(tag, null) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/AbstractMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/AbstractMenu.kt @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.ui.menu - -import me.rhunk.snapenhance.ModContext - -abstract class AbstractMenu { - lateinit var context: ModContext - - open fun init() {} -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt @@ -1,225 +0,0 @@ -package me.rhunk.snapenhance.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.Constants -import me.rhunk.snapenhance.core.util.protobuf.ProtoReader -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.ui.ViewTagState -import me.rhunk.snapenhance.ui.applyTheme -import me.rhunk.snapenhance.ui.menu.AbstractMenu -import java.time.Instant - - -@SuppressLint("DiscouragedApi") -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 chatActionMenuItemMargin by lazy { - context.androidContext.resources.getDimensionPixelSize( - context.androidContext.resources.getIdentifier( - "chat_action_menu_item_margin", - "dimen", - Constants.SNAPCHAT_PACKAGE_NAME - ) - ) - } - - private val actionMenuItemHeight by lazy { - context.androidContext.resources.getDimensionPixelSize( - context.androidContext.resources.getIdentifier( - "action_menu_item_height", - "dimen", - Constants.SNAPCHAT_PACKAGE_NAME - ) - ) - } - - private fun createContainer(viewGroup: ViewGroup): LinearLayout { - val parent = viewGroup.parent.parent as ViewGroup - - return LinearLayout(viewGroup.context).apply layout@{ - orientation = LinearLayout.VERTICAL - layoutParams = MarginLayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - applyTheme(parent.width, true) - setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) - } - } - } - - private fun copyAlertDialog(context: Context, title: String, text: String) { - ViewAppearanceHelper.newAlertDialogBuilder(context).apply { - setTitle(title) - setMessage(text) - setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - setNegativeButton("Copy") { _, _ -> - this@ChatActionMenu.context.copyToClipboard(text, title) - } - }.show() - } - - private val lastFocusedMessage - 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 - //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 messaging = context.feature(Messaging::class) - val messageLogger = context.feature(MessageLogger::class) - - val buttonContainer = createContainer(viewGroup) - - val injectButton = { button: Button -> - if (buttonContainer.childCount > 0) { - buttonContainer.addView(View(viewGroup.context).apply { - layoutParams = MarginLayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - height = 1 - } - setBackgroundColor(0x1A000000) - }) - } - - with(button) { - applyTheme(parent.width, true) - layoutParams = MarginLayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - height = actionMenuItemHeight + defaultGap - } - buttonContainer.addView(this) - } - } - - if (context.config.downloader.chatDownloadContextMenu.get()) { - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation["chat_action_menu.preview_button"] - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { feature(MediaDownloader::class).onMessageActionMenu(true) } - } - }) - - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation["chat_action_menu.download_button"] - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { - feature(MediaDownloader::class).onMessageActionMenu(false) - } - } - }) - } - - //delete logged message button - if (context.config.messaging.messageLogger.get()) { - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation["chat_action_menu.delete_logged_message_button"] - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { - messageLogger.deleteMessage(messaging.openedConversationUUID.toString(), messaging.lastFocusedMessageId) - } - } - }) - } - - if (context.isDeveloper) { - parent.addView(createContainer(viewGroup).apply { - val debugText = StringBuilder() - - setOnClickListener { - this@ChatActionMenu.context.copyToClipboard(debugText.toString(), "debug") - } - - addView(TextView(viewGroup.context).apply { - setPadding(20, 20, 20, 20) - textSize = 10f - addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - val arroyoMessage = lastFocusedMessage ?: return@addOnLayoutChangeListener - text = debugText.apply { - runCatching { - clear() - append("sender_id: ${arroyoMessage.senderId}\n") - append("client_id: ${arroyoMessage.clientMessageId}, server_id: ${arroyoMessage.serverMessageId}\n") - append("conversation_id: ${arroyoMessage.clientConversationId}\n") - append("arroyo_content_type: ${ContentType.fromId(arroyoMessage.contentType)} (${arroyoMessage.contentType})\n") - append("parsed_content_type: ${ContentType.fromMessageContainer( - ProtoReader(arroyoMessage.messageContent!!).followPath(4, 4) - ).let { "$it (${it?.id})" }}\n") - append("creation_timestamp: ${arroyoMessage.creationTimestamp} (${Instant.ofEpochMilli(arroyoMessage.creationTimestamp)})\n") - append("read_timestamp: ${arroyoMessage.readTimestamp} (${Instant.ofEpochMilli(arroyoMessage.readTimestamp)})\n") - append("is_messagelogger_deleted: ${messageLogger.isMessageDeleted(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong())}\n") - append("is_messagelogger_stored: ${messageLogger.getMessageObject(arroyoMessage.clientConversationId!!, arroyoMessage.clientMessageId.toLong()) != null}\n") - }.onFailure { - debugText.append("Error: $it\n") - } - }.toString().trimEnd() - } - }) - - // action buttons - addView(LinearLayout(viewGroup.context).apply { - orientation = LinearLayout.HORIZONTAL - addView(Button(viewGroup.context).apply { - text = "Show Deleted Message Object" - setOnClickListener { - val message = lastFocusedMessage ?: return@setOnClickListener - copyAlertDialog( - viewGroup.context, - "Deleted Message Object", - messageLogger.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.toString() - ?: "null" - ) - } - }) - }) - }) - } - - parent.addView(buttonContainer) - } -} 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 @@ -1,255 +0,0 @@ -package me.rhunk.snapenhance.ui.menu.impl - -import android.content.DialogInterface -import android.content.res.Resources -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.view.View -import android.widget.Button -import android.widget.CompoundButton -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.protobuf.ProtoReader -import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.FriendLinkType -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.spying.MessageLogger -import me.rhunk.snapenhance.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.ui.applyTheme -import me.rhunk.snapenhance.ui.menu.AbstractMenu -import java.net.HttpURLConnection -import java.net.URL -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale - -class FriendFeedInfoMenu : AbstractMenu() { - private fun getImageDrawable(url: String): Drawable { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connect() - val input = connection.inputStream - return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) - } - - private fun formatDate(timestamp: Long): String? { - return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) - } - - private fun showProfileInfo(profile: FriendInfo) { - var icon: Drawable? = null - try { - if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { - icon = getImageDrawable( - BitmojiSelfie.getBitmojiSelfie( - profile.bitmojiSelfieId.toString(), - profile.bitmojiAvatarId.toString(), - BitmojiSelfie.BitmojiSelfieType.THREE_D - )!! - ) - } - } catch (e: Throwable) { - context.log.error("Error loading bitmoji selfie", e) - } - val finalIcon = icon - val translation = context.translation.getCategory("profile_info") - - context.runOnUiThread { - val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) - val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - builder.setIcon(finalIcon) - builder.setTitle(profile.displayName ?: profile.username) - - val birthday = Calendar.getInstance() - birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 - - builder.setMessage(mapOf( - translation["first_created_username"] to profile.firstCreatedUsername, - translation["mutable_username"] to profile.mutableUsername, - translation["display_name"] to profile.displayName, - translation["added_date"] to formatDate(addedTimestamp), - null to birthday.getDisplayName( - Calendar.MONTH, - Calendar.LONG, - context.translation.loadedLocale - )?.let { - context.translation.format("profile_info.birthday", - "month" to it, - "day" to profile.birthday.toInt().toString()) - }, - translation["friendship"] to run { - translation.getCategory("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 { - translation.getCategory("snapchat_plus_state")[if (profile.postViewEmoji != null) "subscribed" else "not_subscribed"] - } - ).filterValues { it != null }.map { - line -> "${line.key?.let { "$it: " } ?: ""}${line.value}" - }.joinToString("\n")) - - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - builder.show() - } - } - - private fun showPreview(userId: String?, conversationId: String) { - //query message - val messageLogger = context.feature(MessageLogger::class) - val messages: List<ConversationMessage> = context.database.getMessagesFromConversationId( - conversationId, - context.config.messaging.messagePreviewLength.get() - )?.reversed() ?: emptyList() - - val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! - .map { context.database.getFriendInfo(it)!! } - .associateBy { it.userId!! } - - val messageBuilder = StringBuilder() - - messages.forEach { message -> - val sender = participants[message.senderId] - val protoReader = ( - messageLogger.takeIf { it.isEnabled }?.getMessageProto(conversationId, message.clientMessageId.toLong()) ?: ProtoReader(message.messageContent ?: return@forEach).followPath(4, 4) - ) ?: return@forEach - - val contentType = ContentType.fromMessageContainer(protoReader) ?: ContentType.fromId(message.contentType) - var messageString = if (contentType == ContentType.CHAT) { - protoReader.getString(2, 1) ?: return@forEach - } else { - contentType.name - } - - if (contentType == ContentType.SNAP) { - messageString = "\uD83D\uDFE5" //red square - if (message.readTimestamp > 0) { - messageString += " \uD83D\uDC40 " //eyes - messageString += DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT - ).format(Date(message.readTimestamp)) - } - } - - var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation["conversation_preview.unknown_user"] - - if (displayUsername.length > 12) { - displayUsername = displayUsername.substring(0, 13) + "... " - } - - messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") - } - - val targetPerson = if (userId == null) null else participants[userId] - - targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { - val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() - messageBuilder.append("\n") - .append("\uD83D\uDD25 ") //fire emoji - .append( - context.translation.format("conversation_preview.streak_expiration", - "day" to (timeSecondDiff / 60 / 24).toString(), - "hour" to (timeSecondDiff / 60 % 24).toString(), - "minute" to (timeSecondDiff % 60).toString() - )) - } - - messages.lastOrNull()?.let { - messageBuilder - .append("\n\n") - .append(context.translation.format("conversation_preview.total_messages", "count" to it.serverMessageId.toString())) - .append("\n") - } - - //alert dialog - val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - builder.setTitle(context.translation["conversation_preview.title"]) - builder.setMessage(messageBuilder.toString()) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - targetPerson?.let { - builder.setNegativeButton(context.translation["modal_option.profile_info"]) { _, _ -> - context.executeAsync { showProfileInfo(it) } - } - } - builder.show() - } - - private fun getCurrentConversationInfo(): Pair<String, String?> { - val messaging = context.feature(Messaging::class) - val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString() - - //mapped conversation fetch (may not work with legacy sc versions) - messaging.lastFetchGroupConversationUUID?.let { - context.database.getFeedEntryByConversationId(it.toString())?.let { friendFeedInfo -> - val participantSize = friendFeedInfo.participantsSize - return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null - } - throw IllegalStateException("No conversation found") - } - - //old conversation fetch - val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) { - val conversation: UserConversationLink = context.database.getConversationLinkFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found") - conversation.clientConversationId!!.trim().lowercase() - } else { - messaging.lastFetchConversationUUID.toString() - } - - return conversationId to focusedConversationTargetUser - } - - private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { - val switch = Switch(context.androidContext) - switch.text = context.translation[text] - switch.isChecked = isChecked() - switch.applyTheme(hasRadius = true) - switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> - toggle(checked) - } - viewConsumer(switch) - } - - fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { - val modContext = context - - val friendFeedMenuOptions by context.config.userInterface.friendFeedMenuButtons - if (friendFeedMenuOptions.isEmpty()) return - - 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) - } - - modContext.features.getRuleFeatures().forEach { ruleFeature -> - if (!friendFeedMenuOptions.contains(ruleFeature.ruleType.key)) return@forEach - - val ruleState = ruleFeature.getRuleState() ?: return@forEach - createToggleFeature(viewConsumer, - ruleFeature.ruleType.translateOptionKey(ruleState.key), - { ruleFeature.getState(conversationId) }, - { ruleFeature.setState(conversationId, it) } - ) - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt @@ -1,146 +0,0 @@ -package me.rhunk.snapenhance.ui.menu.impl - -import android.annotation.SuppressLint -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.ui.ViewTagState -import java.lang.reflect.Modifier - -@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 newChatString by lazy { - context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) - } - - @SuppressLint("ResourceType") - override fun asyncOnActivityCreate() { - friendFeedInfoMenu.context = context - operaContextActionMenu.context = context - chatActionMenu.context = context - settingMenu.context = context - settingsGearInjector.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) - - context.event.subscribe(AddViewEvent::class) { event -> - val originalAddView: (View) -> Unit = { - event.adapter.invokeOriginal(arrayOf(it, -1, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )) - ) - } - - val viewGroup: ViewGroup = event.parent - val childView: View = event.view - operaContextActionMenu.inject(event.parent, childView) - - if (event.parent.id == componentsHolder && childView.id == feedNewChat) { - settingsGearInjector.inject(event.parent, childView) - 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) - return@subscribe - } - - //TODO: inject in group chat menus - if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchGroupConversationUUID != null) { - val injectedLayout = LinearLayout(childView.context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.BOTTOM - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - addView(childView) - addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - messaging.lastFetchGroupConversationUUID = null - } - }) - } - - val viewList = mutableListOf<View>() - context.runOnUiThread { - friendFeedInfoMenu.inject(injectedLayout) { view -> - view.layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 5, 0, 5) - } - viewList.add(view) - } - - viewList.add(View(injectedLayout.context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 30 - ) - }) - - viewList.reversed().forEach { injectedLayout.addView(it, 0) } - } - - event.view = injectedLayout - } - - if (viewGroup is LinearLayout && viewGroup.id == actionSheetItemsContainerLayoutId) { - val itemStringInterface by lazy { - childView.javaClass.declaredFields.filter { - !it.type.isPrimitive && Modifier.isAbstract(it.type.modifiers) - }.map { - runCatching { - it.isAccessible = true - it[childView] - }.getOrNull() - }.firstOrNull() - } - - //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) - viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - viewTagState.removeState(viewGroup) - } - }) - viewTagState[viewGroup] - return@subscribe - } - if (messaging.lastFetchConversationUUID == null || messaging.lastFetchConversationUserUUID == null) return@subscribe - - //filter by the slot index - if (viewGroup.getChildCount() != context.config.userInterface.friendFeedMenuPosition.get()) return@subscribe - if (viewTagState[viewGroup]) return@subscribe - friendFeedInfoMenu.inject(viewGroup, originalAddView) - } - } - } -}- \ No newline at end of file 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 @@ -1,93 +0,0 @@ -package me.rhunk.snapenhance.ui.menu.impl - -import android.annotation.SuppressLint -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.ScrollView -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.ui.applyTheme -import me.rhunk.snapenhance.ui.menu.AbstractMenu - -@SuppressLint("DiscouragedApi") -class OperaContextActionMenu : AbstractMenu() { - private val contextCardsScrollView by lazy { - context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) - } - - /* - LinearLayout : - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - */ - private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { - if (viewGroup !is LinearLayout) return false - val children = ArrayList<View>() - for (i in 0 until viewGroup.getChildCount()) - children.add(viewGroup.getChildAt(i)) - return if (children.any { view: View? -> view !is LinearLayout }) - false - else children.map { view: View -> view as LinearLayout } - .any { linearLayout: LinearLayout -> - val viewChildren = ArrayList<View>() - for (i in 0 until linearLayout.childCount) viewChildren.add( - linearLayout.getChildAt( - i - ) - ) - viewChildren.any { viewChild: View -> - viewChild.javaClass.name.endsWith("SnapFontTextView") - } - } - } - - @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup, childView: View) { - 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 - - val linearLayout = LinearLayout(childView.getContext()) - linearLayout.orientation = LinearLayout.VERTICAL - linearLayout.gravity = Gravity.CENTER - linearLayout.layoutParams = - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - val translation = context.translation - val mediaDownloader = context.feature(MediaDownloader::class) - - linearLayout.addView(Button(childView.getContext()).apply { - text = translation["opera_context_menu.download"] - setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync() } - applyTheme(isAmoled = false) - }) - - if (context.isDeveloper) { - linearLayout.addView(Button(childView.getContext()).apply { - text = "Show debug info" - setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } - applyTheme(isAmoled = false) - }) - } - - (childView 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/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsGearInjector.kt @@ -1,85 +0,0 @@ -package me.rhunk.snapenhance.ui.menu.impl - -import android.annotation.SuppressLint -import android.content.Intent -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.ImageView -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.core.BuildConfig -import me.rhunk.snapenhance.ui.menu.AbstractMenu - - -@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) - } - } - - @SuppressLint("SetTextI18n", "ClickableViewAccessibility") - fun inject(parent: ViewGroup, child: View) { - val firstView = (child as ViewGroup).getChildAt(0) - - child.clipChildren = false - child.addView(FrameLayout(parent.context).apply { - layoutParams = FrameLayout.LayoutParams(firstView.layoutParams.width, firstView.layoutParams.height).apply { - y = 0f - x = -(ngsHovaHeaderSearchIconBackgroundMarginLeft + firstView.layoutParams.width).toFloat() - } - - isClickable = true - - setOnClickListener { - /* val intent = Intent().apply { - setClassName(BuildConfig.APPLICATION_ID, "me.rhunk.snapenhance.ui.manager.MainActivity") - putExtra("route", "features") - } - context.startActivity(intent)*/ - this@SettingsGearInjector.context.bridgeClient.openSettingsOverlay() - } - - parent.setOnTouchListener { _, event -> - if (child.visibility == View.INVISIBLE || child.alpha == 0F) return@setOnTouchListener false - - val viewLocation = IntArray(2) - getLocationOnScreen(viewLocation) - - val x = event.rawX - viewLocation[0] - val y = event.rawY - viewLocation[1] - - if (x > 0 && x < width && y > 0 && y < height) { - performClick() - } - - false - } - backgroundTintList = firstView.backgroundTintList - background = firstView.background - - addView(ImageView(context).apply { - layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 17).apply { - gravity = android.view.Gravity.CENTER - } - setImageDrawable(settingsSvg) - headerButtonOpaqueIconTint?.let { - imageTintList = it - } - }) - }) - } -}- \ No newline at end of file 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 @@ -1,29 +0,0 @@ -package me.rhunk.snapenhance.ui.menu.impl - -import android.annotation.SuppressLint -import android.view.View -import me.rhunk.snapenhance.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/res/drawable/back_arrow.xml b/core/src/main/res/drawable/back_arrow.xml @@ -1,11 +0,0 @@ -<vector android:height="25dp" android:viewportHeight="512" - android:viewportWidth="512" android:width="25dp" xmlns:android="http://schemas.android.com/apk/res/android"> - <path android:fillColor="#00000000" - android:pathData="M244,400l-144,-144l144,-144" - android:strokeColor="#FFFFFF" android:strokeLineCap="round" - android:strokeLineJoin="round" android:strokeWidth="48"/> - <path android:fillColor="#00000000" - android:pathData="M120,256L412,256" - android:strokeColor="#FFFFFF" android:strokeLineCap="round" - android:strokeLineJoin="round" android:strokeWidth="48"/> -</vector> diff --git a/core/src/main/res/font/avenir_next_bold.ttf b/core/src/main/res/font/avenir_next_bold.ttf Binary files differ. diff --git a/core/src/main/res/layout/activity_default_header.xml b/core/src/main/res/layout/activity_default_header.xml @@ -1,36 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<FrameLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:id="@+id/title_bar" - android:layout_width="match_parent" - android:layout_height="50dp" - tools:ignore="UselessParent"> - - <ImageButton - android:id="@+id/back_button" - android:layout_width="45dp" - android:layout_height="match_parent" - android:background="@null" - android:src="@drawable/back_arrow" - android:layout_gravity="center_vertical|start" - android:padding="8dp" - tools:ignore="ContentDescription" /> - - <View - android:layout_width="match_parent" - android:layout_height="1dp" - android:background="@color/borderColor" - android:layout_gravity="bottom" /> - - <TextView - android:id="@+id/title" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:gravity="center" - android:text="" - android:textColor="@color/primaryText" - android:textSize="23sp" - android:fontFamily="@font/avenir_next_bold" /> - -</FrameLayout>- \ No newline at end of file diff --git a/core/src/main/res/layout/debug_setting_item.xml b/core/src/main/res/layout/debug_setting_item.xml @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="60dp" - android:orientation="horizontal" - android:padding="16dp"> - - <TextView - android:id="@+id/feature_text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_weight="1" - android:text="" - android:textColor="@color/primaryText" - android:textSize="16sp" /> - -</LinearLayout>- \ No newline at end of file diff --git a/core/src/main/res/layout/debug_settings_page.xml b/core/src/main/res/layout/debug_settings_page.xml @@ -1,21 +0,0 @@ -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/setting_page" - android:clickable="true" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/primaryBackground" - android:orientation="vertical"> - - <include - android:id="@+id/title_bar" - layout="@layout/activity_default_header" /> - - <ListView - android:id="@+id/setting_page_list" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:listSelector="@android:color/transparent" - android:orientation="vertical"> - </ListView> - -</LinearLayout> diff --git a/core/src/main/res/layout/device_spoofer_activity.xml b/core/src/main/res/layout/device_spoofer_activity.xml @@ -1,27 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:orientation="vertical" - android:background="@color/primaryBackground" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <include - android:id="@+id/title_bar" - layout="@layout/activity_default_header" /> - - <ScrollView - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="true"> - - <LinearLayout - android:id="@+id/spoof_property_list" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical"> - </LinearLayout> - - </ScrollView> - -</LinearLayout>- \ No newline at end of file diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string-array name="xposed_scope"> - <item>com.snapchat.android</item> - </string-array> -</resources>- \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { rootProject.name = "SnapEnhance" +include(":common") include(":core") include(":app") include(":mapper")