refactor: bridge and mapper (#130)

* feat: fingerprint spoof

* fetch upstream - fix errors

* fix(ci): wrong env

* fix all items being shown and translations

* use filter instead of check

* fix(snap_enums/ContentType): status plus gift

* refactor: download bridge

* fix: check if bridge svc is correctly installed

* refactor bridge
- async handlers
- optimized reply callback calls
- add timeout debug when sending a bridge msg

* fix: check for bridge correctly initialized instead of connected

* fix: android compat + suppress warnings

* fix(feature/manager): set a fixed thread pool

* feat: dex mapper

* build: libs catalog

* fix: infinite loading

* refactor: config ui items

* feat: disable google play services dialogs

* fix(mapper): getClassName

* fix(bridge/force_start): use broadcast receiver instead of activity

* fix: notification filter

* refactor: aidl

* fix(bridge): bindServiceAsUser handler

* refactor: download manager

* fix(download/callback): add outputPath

* fix(mapper): lspatch support

* fix(notifications): blacklist & reply
- config value container translation key

* feat: download from notifications

* fix(notification/reply): use FLAG_ONLY_ALERT_ONCE

* fix: alert dialog builder theme

* hooker method inline

* fix: immersive camera preview

* fix: chat action menu design

* fix(download/processor): fallback toasts

* fix(message_logger): limit thread pool to 10

* feat(download): logging

* fix: screenshot detection in snaps

* feat: hide friend suggestions

* refactor: download content URIs

* feat: preview group chats

* fix(config): ask for save folder

* fix(download_manager): async item preview

* fix(media_downloader): story replies

* fix(notifications): concurrent exception

* build: kotlin dsl

* fix(ci): upload only on main

* fix(build): getVersion task

* fix(build): abi filters

* fix(build): default product flavor

* test(mapper): mapping tests

* fix(mappings): group chat preview

* fix(mapper): dexlib interfaces instead of impl

---------

Co-authored-by: auth <64337177+authorisation@users.noreply.github.com>
This commit is contained in:
rhunk
2023-07-27 15:32:50 +02:00
committed by GitHub
parent 151c804629
commit ce9833f2c3
124 changed files with 2676 additions and 2367 deletions

View File

@@ -71,7 +71,9 @@ jobs:
path: app/build/outputs/apk/armv7/debug/*.apk
- name: CI Upload armv8
if: github.ref_name == 'main'
run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv8/debug/app-${{ env.version }}-armv8-${{ steps.version-env.outputs.sha_short }}.apk" --caption "A new commit has been pushed to the ${{ env.GIT_BRANCH_NAME }} branch! ${{ steps.version-env.outputs.sha_short }}" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}"
- name: CI Upload armv7
if: github.ref_name == 'main'
run: node ./.github/workflows/upload.js -t "${{ secrets.TELEGRAM_BOT_TOKEN }}" -f "app/build/outputs/apk/armv7/debug/app-${{ env.version }}-armv7-${{ steps.version-env.outputs.sha_short }}.apk" --chatid "${{ secrets.TELEGRAM_CHAT_ID }}"

View File

@@ -1,110 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
def appVersionName = "1.1.0"
def appVersionCode = 7
android {
compileSdk 33
buildToolsVersion = "33.0.2"
defaultConfig {
applicationId "me.rhunk.snapenhance"
minSdk 28
targetSdk 33
versionCode appVersionCode
versionName appVersionName
multiDexEnabled true
}
buildTypes {
release {
minifyEnabled false
shrinkResources false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
applicationVariants.configureEach { variant ->
variant.outputs.configureEach {
outputFileName = "app-${appVersionName}-${variant.flavorName}.apk"
}
}
flavorDimensions "release"
productFlavors {
armv8 {
getIsDefault().set(true)
ndk {
//noinspection ChromeOsAbiSupport
abiFilters "arm64-v8a"
}
dimension "release"
}
armv7 {
ndk {
//noinspection ChromeOsAbiSupport
abiFilters "armeabi-v7a"
}
packagingOptions {
exclude 'lib/armeabi-v7a/*_neon.so'
}
dimension "release"
}
}
kotlinOptions {
jvmTarget = '1.8'
}
namespace 'me.rhunk.snapenhance'
}
afterEvaluate {
//auto install for debug purpose
getTasks().getByPath(":app:assembleArmv8Debug").doLast {
def apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == "armv8" }.outputs[0].outputFile
try {
println "Killing Snapchat"
exec {
commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android"
}
println "Installing debug build"
exec() {
commandLine "adb", "install", "-r", "-d", apkDebugFile.absolutePath
}
println "Starting Snapchat"
exec {
commandLine "adb", "shell", "am", "start", "com.snapchat.android"
}
} catch (Throwable t) {
println "Failed to install debug build"
t.printStackTrace()
}
}
}
task getVersion {
doLast {
def version = new File('app/build/version.txt')
version.text = android.defaultConfig.versionName
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1.LTS'
implementation 'org.osmdroid:osmdroid-android:6.1.16'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
}

119
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,119 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
}
val appVersionName = "1.1.0"
val appVersionCode = 7
android {
namespace = "me.rhunk.snapenhance"
compileSdk = 33
buildFeatures {
aidl = true
}
defaultConfig {
applicationId = "me.rhunk.snapenhance"
minSdk = 28
//noinspection OldTargetApi
targetSdk = 33
versionCode = appVersionCode
versionName = appVersionName
multiDexEnabled = true
}
buildTypes {
release {
isMinifyEnabled = false
isShrinkResources = false
}
}
flavorDimensions += "abi"
productFlavors {
create("armv8") {
ndk {
abiFilters.add("arm64-v8a")
}
dimension = "abi"
}
create("armv7") {
ndk {
abiFilters.add("armeabi-v7a")
}
packaging {
jniLibs {
excludes += "**/*_neon.so"
}
}
dimension = "abi"
}
}
properties["debug_flavor"]?.let {
android.productFlavors[it.toString()].setIsDefault(true)
}
applicationVariants.all {
outputs.map { it as BaseVariantOutputImpl }.forEach { variant ->
variant.outputFileName = "app-${appVersionName}-${variant.name}.apk"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
compileOnly(files("libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(libs.coroutines)
implementation(libs.kotlin.reflect)
implementation(libs.recyclerview)
implementation(libs.gson)
implementation(libs.ffmpeg.kit)
implementation(libs.osmdroid.android)
implementation(libs.okhttp)
implementation(libs.androidx.documentfile)
implementation(project(":mapper"))
}
tasks.register("getVersion") {
doLast {
val versionFile = File("app/build/version.txt")
versionFile.writeText(android.defaultConfig.versionName.toString())
}
}
afterEvaluate {
properties["debug_assemble_task"]?.let { tasks.named(it.toString()) }?.orNull?.doLast {
runCatching {
val apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == properties["debug_flavor"] }?.outputs?.first()?.outputFile ?: return@doLast
exec {
commandLine("adb", "shell", "am", "force-stop", "com.snapchat.android")
}
exec {
commandLine("adb", "install", "-r", "-d", apkDebugFile.absolutePath)
}
exec {
commandLine("adb", "shell", "am", "start", "com.snapchat.android")
}
}
}
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -32,18 +32,11 @@
android:resource="@array/sc_scope" />
<service
android:name=".bridge.service.BridgeService"
android:name=".bridge.BridgeService"
android:exported="true"
tools:ignore="ExportedService">
</service>
<receiver android:name=".download.DownloadManagerReceiver" android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="me.rhunk.snapenhance.download.DownloadManagerReceiver.DOWNLOAD_ACTION" />
</intent-filter>
</receiver>
<activity
android:name=".ui.download.DownloadManagerActivity"
android:theme="@style/AppTheme"
@@ -62,6 +55,15 @@
android:theme="@style/AppTheme"
android:excludeFromRecents="true"
android:exported="true" />
<activity
android:name=".ui.spoof.DeviceSpooferActivity"
android:theme="@style/AppTheme"
android:excludeFromRecents="true"
android:exported="true" />
<activity android:name=".bridge.ForceStartActivity"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:exported="true" />
</application>
</manifest>

View File

@@ -0,0 +1,108 @@
package me.rhunk.snapenhance.bridge;
import java.util.List;
import me.rhunk.snapenhance.bridge.DownloadCallback;
interface BridgeInterface {
/**
* Create a file if it doesn't exist, and read it
*
* @param fileType the type of file to create and read
* @param defaultContent the default content to write to the file if it doesn't exist
* @return the content of the file
*/
byte[] createAndReadFile(int fileType, in byte[] defaultContent);
/**
* Read a file
*
* @param fileType the type of file to read
* @return the content of the file
*/
byte[] readFile(int fileType);
/**
* Write a file
*
* @param fileType the type of file to write
* @param content the content to write to the file
* @return true if the file was written successfully
*/
boolean writeFile(int fileType, in byte[] content);
/**
* Delete a file
*
* @param fileType the type of file to delete
* @return true if the file was deleted successfully
*/
boolean deleteFile(int fileType);
/**
* Check if a file exists
*
* @param fileType the type of file to check
* @return true if the file exists
*/
boolean isFileExists(int fileType);
/**
* Get the content of a logged message from the database
*
* @param conversationId the ID of the conversation
* @return the content of the message
*/
long[] getLoggedMessageIds(String conversationId, int limit);
/**
* Get the content of a logged message from the database
*
* @param id the ID of the message logger message
* @return the content of the message
*/
@nullable byte[] getMessageLoggerMessage(String conversationId, long id);
/**
* Add a message to the message logger database
*
* @param id the ID of the message logger message
* @param message the content of the message
*/
void addMessageLoggerMessage(String conversationId, long id, in byte[] message);
/**
* Delete a message from the message logger database
*
* @param id the ID of the message logger message
*/
void deleteMessageLoggerMessage(String conversationId, long id);
/**
* Clear the message logger database
*/
void clearMessageLogger();
/**
* Fetch the translations
*
* @return the translations result
*/
Map<String, String> fetchTranslations();
/**
* Get check for updates last time
* @return the last time check for updates was done
*/
long getAutoUpdaterTime();
/**
* Set check for updates last time
* @param time the time to set
*/
void setAutoUpdaterTime(long time);
/**
* Enqueue a download
*/
void enqueueDownload(in Intent intent, DownloadCallback callback);
}

View File

@@ -0,0 +1,7 @@
package me.rhunk.snapenhance.bridge;
oneway interface DownloadCallback {
void onSuccess(String outputPath);
void onProgress(String message);
void onFailure(String message, @nullable String throwable);
}

View File

@@ -50,13 +50,9 @@
"name": "Unlimited Snap View Time",
"description": "Removes the time limit for viewing Snaps"
},
"prevent_screenshot_notifications": {
"name": "Prevent Screenshot Notifications",
"description": "Prevents anyone from knowing that you've taken a screenshot"
},
"prevent_status_notifications": {
"name": "Prevent Status Notifications",
"description": "Prevents sending status notifications\ne.g. Saved to camera roll"
"prevent_sending_messages": {
"name": "Prevent Sending Messages",
"description": "Prevents sending certain types of messages"
},
"anonymous_story_view": {
"name": "Anonymous Story View",
@@ -95,6 +91,10 @@
"name": "Force Media Source Quality",
"description": "Overrides the media source quality"
},
"download_logging": {
"name": "Download Logging",
"description": "Show a toast when media is downloading"
},
"enable_friend_feed_menu_bar": {
"name": "Friend Feed Menu Bar",
@@ -164,6 +164,10 @@
"name": "Override Startup Page",
"description": "Overrides the startup page"
},
"disable_google_play_dialogs": {
"name": "Disable Google Play Services Dialogs",
"description": "Prevent Google Play Services availability dialogs from being shown"
},
"auto_updater": {
"name": "Auto Updater",
@@ -218,6 +222,18 @@
"unlimited_multi_snap": {
"name": "Unlimited Multi Snap",
"description": "Allows you to take an unlimited amount of multi snaps"
},
"device_spoof": {
"name": "Spoof Device Values",
"description": "Spoofs the devices values"
},
"device_fingerprint": {
"name": "Device Fingerprint",
"description": "Spoofs the device fingerprint"
},
"android_id": {
"name": "Android ID",
"description": "Spoofs the devices Android ID"
}
},
@@ -226,7 +242,8 @@
"better_notifications": {
"chat": "Show chat messages",
"snap": "Show medias",
"reply_button": "Add reply button"
"reply_button": "Add reply button",
"download_button": "Add download button"
},
"friend_feed_menu_buttons": {
"auto_download_blacklist": "\u2B07\uFE0F Auto Download Blacklist",
@@ -249,6 +266,12 @@
"public_stories": "Public Stories",
"spotlight": "Spotlight"
},
"download_logging": {
"started": "Started",
"success": "Success",
"progress": "Progress",
"failure": "Failure"
},
"auto_save_messages": {
"NOTE": "Audio Note",
"CHAT": "Chat",
@@ -256,10 +279,19 @@
"SNAP": "Snap",
"STICKER": "Sticker"
},
"notification_blacklist": {
"notifications": {
"chat_screenshot": "Screenshot",
"chat_screen_record": "Screen Record",
"camera_roll_save": "Camera Roll Save",
"chat": "Chat",
"chat_reply": "Chat Reply",
"snap": "Snap",
"typing": "Typing"
"typing": "Typing",
"stories": "Stories",
"initiate_audio": "Incoming Audio Call",
"abandon_audio": "Missed Audio Call",
"initiate_video": "Incoming Video Call",
"abandon_video": "Missed Video Call"
},
"gallery_media_send_override": {
"OFF": "Off",
@@ -287,6 +319,7 @@
"VERTICAL_STORY_VIEWER": "Enable Vertical Story Viewer"
},
"hide_story_section": {
"hide_friend_suggestions": "Hide friend suggestions",
"hide_friends": "Hide friends section",
"hide_following": "Hide following section",
"hide_for_you": "Hide For You section"
@@ -401,16 +434,21 @@
"clear_cache_title": "Clear Cache",
"reset_all_title": "Reset all settings",
"reset_all_confirmation": "Are you sure you want to reset all settings?",
"success_toast": "Success!"
"success_toast": "Success!",
"device_spoofer": "Device Spoofer"
}
},
"download_manager_receiver": {
"download_processor": {
"download_started_toast": "Download started",
"unsupported_content_type_toast": "Unsupported content type!",
"failed_no_longer_available_toast": "Media no longer available",
"already_queued_toast": "Media already in queue!",
"already_downloaded_toast": "Media already downloaded!",
"saved_toast": "Saved to {path}",
"download_toast": "Downloading {path}...",
"processing_toast": "Processing {path}...",
"failed_generic_toast": "Failed to download",
"failed_to_create_preview_toast": "Failed to create preview",
"failed_processing_toast": "Failed to process {error}",
"failed_gallery_toast": "Failed to save to gallery {error}"
},
@@ -418,5 +456,8 @@
"title": "SnapEnhance Settings",
"selected_text": "{count} selected",
"invalid_number_toast": "Invalid number!"
},
"spoof_activity": {
"title": "Spoof Settings"
}
}

View File

@@ -11,9 +11,9 @@ import android.widget.Toast
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import kotlinx.coroutines.asCoroutineDispatcher
import me.rhunk.snapenhance.bridge.AbstractBridgeClient
import me.rhunk.snapenhance.bridge.ConfigWrapper
import me.rhunk.snapenhance.bridge.TranslationWrapper
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.bridge.wrapper.ConfigWrapper
import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper
import me.rhunk.snapenhance.data.MessageSender
import me.rhunk.snapenhance.database.DatabaseAccess
import me.rhunk.snapenhance.features.Feature
@@ -35,7 +35,7 @@ class ModContext {
lateinit var androidContext: Context
var mainActivity: Activity? = null
lateinit var bridgeClient: AbstractBridgeClient
lateinit var bridgeClient: BridgeClient
val gson: Gson = GsonBuilder().create()

View File

@@ -7,7 +7,8 @@ import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.Settings
import me.rhunk.snapenhance.bridge.TranslationWrapper
import me.rhunk.snapenhance.bridge.wrapper.ConfigWrapper
import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper
import me.rhunk.snapenhance.download.DownloadTaskManager
import kotlin.system.exitProcess
@@ -17,6 +18,7 @@ import kotlin.system.exitProcess
object SharedContext {
lateinit var downloadTaskManager: DownloadTaskManager
lateinit var translation: TranslationWrapper
lateinit var config: ConfigWrapper
private fun askForStoragePermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -36,17 +38,7 @@ object SharedContext {
context.requestPermissions(arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.READ_EXTERNAL_STORAGE), 0)
}
fun ensureInitialized(context: Context) {
if (!this::downloadTaskManager.isInitialized) {
downloadTaskManager = DownloadTaskManager().apply {
init(context)
}
}
if (!this::translation.isInitialized) {
translation = TranslationWrapper().apply {
loadFromContext(context)
}
}
private fun askForPermissions(context: Context) {
//ask for storage permission
val hasStoragePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -72,4 +64,22 @@ object SharedContext {
}
.show()
}
fun ensureInitialized(context: Context) {
if (!this::downloadTaskManager.isInitialized) {
downloadTaskManager = DownloadTaskManager().apply {
init(context)
}
}
if (!this::translation.isInitialized) {
translation = TranslationWrapper().apply {
loadFromContext(context)
}
}
if (!this::config.isInitialized) {
config = ConfigWrapper().apply { loadFromContext(context) }
}
askForPermissions(context)
}
}

View File

@@ -1,16 +1,17 @@
package me.rhunk.snapenhance
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.bridge.AbstractBridgeClient
import me.rhunk.snapenhance.bridge.client.ServiceBridgeClient
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.data.SnapClassCache
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.util.getApplicationInfoCompat
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
@@ -22,16 +23,24 @@ class SnapEnhance {
}
}
private val appContext = ModContext()
private var isBridgeInitialized = false
init {
Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param ->
appContext.androidContext = param.arg<Context>(0).also {
classLoader = it.classLoader
}
appContext.bridgeClient = provideBridgeClient()
appContext.bridgeClient = BridgeClient(appContext)
//for lspatch builds, we need to check if the service is correctly installed
runCatching {
appContext.androidContext.packageManager.getApplicationInfoCompat(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA)
}.onFailure {
appContext.crash("SnapEnhance bridge service is not installed. Please download stable version from https://github.com/rhunk/SnapEnhance/releases")
return@hook
}
appContext.bridgeClient.apply {
this.context = appContext
start { bridgeResult ->
if (!bridgeResult) {
Logger.xposedLog("Cannot connect to bridge service")
@@ -42,6 +51,8 @@ class SnapEnhance {
runBlocking {
init()
}
}.onSuccess {
isBridgeInitialized = true
}.onFailure {
Logger.xposedLog("Failed to initialize", it)
}
@@ -49,7 +60,7 @@ class SnapEnhance {
}
}
Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) {
Activity::class.java.hook( "onCreate", HookStage.AFTER, { isBridgeInitialized }) {
val activity = it.thisObject() as Activity
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
val isMainActivityNotNull = appContext.mainActivity != null
@@ -61,7 +72,7 @@ class SnapEnhance {
var activityWasResumed = false
//we need to reload the config when the app is resumed
Hooker.hook(Activity::class.java, "onResume", HookStage.AFTER) {
Activity::class.java.hook("onResume", HookStage.AFTER, { isBridgeInitialized }) {
val activity = it.thisObject() as Activity
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
@@ -76,14 +87,6 @@ class SnapEnhance {
}
}
@SuppressLint("ObsoleteSdkInt")
private fun provideBridgeClient(): AbstractBridgeClient {
/*if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
return RootBridgeClient()
}*/
return ServiceBridgeClient()
}
@OptIn(ExperimentalTime::class)
private suspend fun init() {
//load translations in a coroutine to speed up initialization
@@ -100,7 +103,7 @@ class SnapEnhance {
features.init()
}
}.also { time ->
Logger.debug("initialized in $time")
Logger.debug("init took $time")
}
}
@@ -112,7 +115,7 @@ class SnapEnhance {
actionManager.init()
}
}.also { time ->
Logger.debug("onActivityCreate in $time")
Logger.debug("onActivityCreate took $time")
}
}
}

View File

@@ -19,6 +19,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.database.objects.FriendFeedInfo
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.export.ExportFormat
import me.rhunk.snapenhance.util.export.MessageExporter
@@ -61,7 +62,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
private suspend fun askExportType() = suspendCancellableCoroutine { cont ->
context.runOnUiThread {
AlertDialog.Builder(context.mainActivity)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["chat_export.select_export_format"])
.setItems(ExportFormat.values().map { it.name }.toTypedArray()) { _, which ->
cont.resumeWith(Result.success(ExportFormat.values()[which]))
@@ -82,7 +83,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
ContentType.NOTE,
ContentType.STICKER
)
AlertDialog.Builder(context.mainActivity)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["chat_export.select_media_type"])
.setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked ->
val media = contentTypes[which]
@@ -110,7 +111,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
val friendFeedEntries = context.database.getFriendFeed(20)
val selectedConversations = mutableListOf<FriendFeedInfo>()
AlertDialog.Builder(context.mainActivity)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["chat_export.select_conversation"])
.setMultiChoiceItems(
friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayName!!.split("|").firstOrNull() }.toTypedArray(),
@@ -248,7 +249,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
dialogLogs.clear()
val jobs = mutableListOf<Job>()
currentActionDialog = AlertDialog.Builder(context.mainActivity)
currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["chat_export.exporting_chats"])
.setCancelable(false)
.setMessage("")

View File

@@ -1,7 +1,7 @@
package me.rhunk.snapenhance.action.impl
import me.rhunk.snapenhance.action.AbstractAction
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
class RefreshMappings : AbstractAction("action.refresh_mappings") {
override fun run() {

View File

@@ -1,122 +0,0 @@
package me.rhunk.snapenhance.bridge
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult
abstract class AbstractBridgeClient {
lateinit var context: ModContext
/**
* Start the bridge client
*
* @param callback the callback to call when the initialization is done
*/
abstract fun start(callback: (Boolean) -> Unit = {})
/**
* Create a file if it doesn't exist, and read it
*
* @param fileType the type of file to create and read
* @param defaultContent the default content to write to the file if it doesn't exist
* @return the content of the file
*/
abstract fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray
/**
* Read a file
*
* @param fileType the type of file to read
* @return the content of the file
*/
abstract fun readFile(fileType: BridgeFileType): ByteArray
/**
* Write a file
*
* @param fileType the type of file to write
* @param content the content to write to the file
* @return true if the file was written successfully
*/
abstract fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean
/**
* Delete a file
*
* @param fileType the type of file to delete
* @return true if the file was deleted successfully
*/
abstract fun deleteFile(fileType: BridgeFileType): Boolean
/**
* Check if a file exists
*
* @param fileType the type of file to check
* @return true if the file exists
*/
abstract fun isFileExists(fileType: BridgeFileType): Boolean
/**
* Download content from a URL and save it to a file
*
* @param url the URL to download content from
* @param path the path to save the content to
* @return true if the content was downloaded successfully
*/
abstract fun downloadContent(url: String, path: String): Boolean
/**
* Get the content of a logged message from the database
*
* @param conversationId the ID of the conversation
* @return the content of the message
*/
abstract fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long>
/**
* Get the content of a logged message from the database
*
* @param id the ID of the message logger message
* @return the content of the message
*/
abstract fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray?
/**
* Add a message to the message logger database
*
* @param id the ID of the message logger message
* @param message the content of the message
*/
abstract fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray)
/**
* Delete a message from the message logger database
*
* @param id the ID of the message logger message
*/
abstract fun deleteMessageLoggerMessage(conversationId: String, id: Long)
/**
* Clear the message logger database
*/
abstract fun clearMessageLogger()
/**
* Fetch the translations
*
* @return the translations result
*/
abstract fun fetchTranslations(): LocaleResult
/**
* Get check for updates last time
* @return the last time check for updates was done
*/
abstract fun getAutoUpdaterTime(): Long
/**
* Set check for updates last time
* @param time the time to set
*/
abstract fun setAutoUpdaterTime(time: Long)
}

View File

@@ -0,0 +1,126 @@
package me.rhunk.snapenhance.bridge
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.data.LocalePair
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.system.exitProcess
class BridgeClient(
private val context: ModContext
): ServiceConnection {
private lateinit var future: CompletableFuture<Boolean>
private lateinit var service: BridgeInterface
fun start(callback: (Boolean) -> Unit) {
this.future = CompletableFuture()
with(context.androidContext) {
//ensure the remote process is running
startActivity(Intent()
.setClassName(BuildConfig.APPLICATION_ID, ForceStartActivity::class.java.name)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
)
val intent = Intent()
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
Executors.newSingleThreadExecutor(),
this@BridgeClient
)
} else {
XposedHelpers.callMethod(
this,
"bindServiceAsUser",
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
Handler(HandlerThread("BridgeClient").apply {
start()
}.looper),
android.os.Process.myUserHandle()
)
}
}
callback(future.get())
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
this.service = BridgeInterface.Stub.asInterface(service)
future.complete(true)
}
override fun onNullBinding(name: ComponentName) {
xposedLog("failed to connect to bridge service")
future.complete(false)
}
override fun onServiceDisconnected(name: ComponentName) {
exitProcess(0)
}
fun createAndReadFile(
fileType: BridgeFileType,
defaultContent: ByteArray
): ByteArray = service.createAndReadFile(fileType.value, defaultContent)
fun readFile(fileType: BridgeFileType): ByteArray = service.readFile(fileType.value)
fun writeFile(
fileType: BridgeFileType,
content: ByteArray?
): Boolean = service.writeFile(fileType.value, content)
fun deleteFile(fileType: BridgeFileType) = service.deleteFile(fileType.value)
fun isFileExists(fileType: BridgeFileType) = service.isFileExists(fileType.value)
fun getLoggedMessageIds(conversationId: String, limit: Int): LongArray = service.getLoggedMessageIds(conversationId, limit)
fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? = service.getMessageLoggerMessage(conversationId, id)
fun addMessageLoggerMessage(conversationId: String,id: Long, message: ByteArray) = service.addMessageLoggerMessage(conversationId, id, message)
fun deleteMessageLoggerMessage(conversationId: String, id: Long) = service.deleteMessageLoggerMessage(conversationId, id)
fun clearMessageLogger() = service.clearMessageLogger()
fun fetchTranslations() = service.fetchTranslations().map {
LocalePair(it.key, it.value)
}
fun getAutoUpdaterTime(): Long {
createAndReadFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, "0".toByteArray()).run {
return if (isEmpty()) {
0
} else {
String(this).toLong()
}
}
}
fun setAutoUpdaterTime(time: Long) {
writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray())
}
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = service.enqueueDownload(intent, callback)
}

View File

@@ -0,0 +1,105 @@
package me.rhunk.snapenhance.bridge
import android.app.Service
import android.content.Intent
import android.os.IBinder
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper
import me.rhunk.snapenhance.download.DownloadProcessor
class BridgeService : Service() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
override fun onBind(intent: Intent): IBinder {
messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
return BridgeBinder()
}
inner class BridgeBinder : BridgeInterface.Stub() {
override fun createAndReadFile(fileType: Int, defaultContent: ByteArray?): ByteArray {
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
?: return defaultContent ?: ByteArray(0)
if (!file.exists()) {
if (defaultContent == null) {
return ByteArray(0)
}
file.writeBytes(defaultContent)
}
return file.readBytes()
}
override fun readFile(fileType: Int): ByteArray {
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
?: return ByteArray(0)
if (!file.exists()) {
return ByteArray(0)
}
return file.readBytes()
}
override fun writeFile(fileType: Int, content: ByteArray?): Boolean {
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
?: return false
if (content == null) {
return false
}
file.writeBytes(content)
return true
}
override fun deleteFile(fileType: Int): Boolean {
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
?: return false
if (!file.exists()) {
return false
}
return file.delete()
}
override fun isFileExists(fileType: Int): Boolean {
val file = BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
?: return false
return file.exists()
}
override fun getLoggedMessageIds(conversationId: String, limit: Int) = messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray()
override fun getMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.getMessage(conversationId, id).second
override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) {
messageLoggerWrapper.addMessage(conversationId, id, message)
}
override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id)
override fun clearMessageLogger() = messageLoggerWrapper.clearMessages()
override fun fetchTranslations() = TranslationWrapper.fetchLocales(context = this@BridgeService).associate {
it.locale to it.content
}
override fun getAutoUpdaterTime(): Long {
throw UnsupportedOperationException()
}
override fun setAutoUpdaterTime(time: Long) {
throw UnsupportedOperationException()
}
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
SharedContext.ensureInitialized(this@BridgeService)
DownloadProcessor(this@BridgeService, callback).onReceive(intent)
}
}
}

View File

@@ -0,0 +1,11 @@
package me.rhunk.snapenhance.bridge
import android.app.Activity
import android.os.Bundle
class ForceStartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
finish()
}
}

View File

@@ -1,155 +0,0 @@
package me.rhunk.snapenhance.bridge.client
import android.os.Environment
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.bridge.AbstractBridgeClient
import me.rhunk.snapenhance.bridge.MessageLoggerWrapper
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.OutputStream
import java.util.zip.ZipInputStream
class RootBridgeClient : AbstractBridgeClient() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
companion object {
private val MOD_FOLDER = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),"SnapEnhance")
}
override fun start(callback: (Boolean) -> Unit) {
if (!MOD_FOLDER.exists()) {
MOD_FOLDER.mkdirs()
}
messageLoggerWrapper = MessageLoggerWrapper(File(MOD_FOLDER, BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
callback(true)
}
override fun createAndReadFile(fileType: BridgeFileType, defaultContent: ByteArray): ByteArray {
val file = File(MOD_FOLDER, fileType.fileName)
if (file.exists()) {
return readFile(fileType)
}
val outputStream = openFileWritable(file)
outputStream.write(defaultContent)
outputStream.close()
return defaultContent
}
override fun readFile(fileType: BridgeFileType): ByteArray {
return File(MOD_FOLDER, fileType.fileName).readBytes()
}
override fun writeFile(fileType: BridgeFileType, content: ByteArray?): Boolean {
val outputStream = openFileWritable(File(MOD_FOLDER, fileType.fileName))
outputStream.write(content)
outputStream.close()
return true
}
override fun deleteFile(fileType: BridgeFileType): Boolean {
val file = File(MOD_FOLDER, fileType.fileName)
val exists = file.exists()
if (exists) {
rootOperation("rm ${file.absolutePath}")
}
return exists
}
override fun isFileExists(fileType: BridgeFileType): Boolean {
return File(MOD_FOLDER, fileType.fileName).exists()
}
override fun downloadContent(url: String, path: String): Boolean {
return true
}
override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> {
return messageLoggerWrapper.getMessageIds(conversationId, limit)
}
override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? {
val (state, messageData) = messageLoggerWrapper.getMessage(conversationId, id)
if (state) {
return messageData
}
return null
}
override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) {
messageLoggerWrapper.addMessage(conversationId, id, message)
}
override fun deleteMessageLoggerMessage(conversationId: String, id: Long) {
messageLoggerWrapper.deleteMessage(conversationId, id)
}
override fun clearMessageLogger() {
messageLoggerWrapper.clearMessages()
}
override fun fetchTranslations(): LocaleResult {
val locale = "en_US"//Locale.getDefault().toString()
//https://github.com/LSPosed/LSPosed/blob/master/core/src/main/java/org/lsposed/lspd/util/LspModuleClassLoader.java#L36
val moduleApk = javaClass.classLoader.javaClass.declaredFields.first { it.type == String::class.java }.let {
it.isAccessible = true
it.get(javaClass.classLoader) as String
}
val langJsonData: ByteArray? = ZipInputStream(FileInputStream(moduleApk)).let { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.name == "assets/lang/$locale.json") {
return@let zip.readBytes()
}
}
return@let null
}
if (langJsonData != null) {
Logger.debug("Fetched translations for $locale")
return LocaleResult(arrayOf(locale), arrayOf(langJsonData.toString(Charsets.UTF_8)))
}
throw Throwable("Failed to fetch translations for $locale")
}
override fun getAutoUpdaterTime(): Long {
readFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP).run {
return if (isEmpty()) {
0
} else {
String(this).toLong()
}
}
}
override fun setAutoUpdaterTime(time: Long) {
writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray(Charsets.UTF_8))
}
private fun rootOperation(command: String): String {
val process = Runtime.getRuntime().exec("su -c $command")
process.waitFor()
process.errorStream?.bufferedReader()?.let {
val error = it.readText()
if (error.isNotEmpty()) {
throw Throwable("Failed to execute root operation: $error")
}
}
Logger.debug("Root operation executed: $command")
return process.inputStream.bufferedReader().readText()
}
private fun openFileWritable(file: File): OutputStream {
runCatching {
if (!file.exists()) rootOperation("touch ${file.absolutePath}")
}.onFailure {
Logger.error("Failed to set file permissions: ${it.message}")
}
return FileOutputStream(file)
}
}

View File

@@ -1,270 +0,0 @@
package me.rhunk.snapenhance.bridge.client
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.Message
import android.os.Messenger
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.bridge.AbstractBridgeClient
import me.rhunk.snapenhance.bridge.common.BridgeMessage
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest
import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest
import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult
import me.rhunk.snapenhance.bridge.service.BridgeService
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.reflect.KClass
import kotlin.system.exitProcess
class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection {
private val handlerThread = HandlerThread("BridgeClient")
private lateinit var messenger: Messenger
private lateinit var future: CompletableFuture<Boolean>
override fun start(callback: (Boolean) -> Unit) {
this.future = CompletableFuture()
this.handlerThread.start()
with(context.androidContext) {
val intent = Intent()
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
Executors.newSingleThreadExecutor(),
this@ServiceBridgeClient
)
} else {
XposedHelpers.callMethod(
this,
"bindServiceAsUser",
intent,
this@ServiceBridgeClient,
Context.BIND_AUTO_CREATE,
Handler(handlerThread.looper),
android.os.Process.myUserHandle()
)
}
}
callback(future.get())
}
private fun handleResponseMessage(
msg: Message
): BridgeMessage {
val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) {
BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult()
BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult()
BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult()
BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT -> MessageLoggerListResult()
BridgeMessageType.LOCALE_RESULT -> LocaleResult()
else -> throw IllegalStateException("Unknown message type: ${msg.what}")
}
with(message) {
read(msg.data)
return this
}
}
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
private fun <T : BridgeMessage> sendMessage(
messageType: BridgeMessageType,
bridgeMessage: BridgeMessage,
resultType: KClass<T>? = null
) = runBlocking {
return@runBlocking suspendCancellableCoroutine<T> { continuation ->
with(Message.obtain()) {
what = messageType.value
replyTo = Messenger(object : Handler(handlerThread.looper) {
override fun handleMessage(msg: Message) {
if (continuation.isCompleted) {
continuation.cancel(Throwable("Already completed"))
return
}
continuation.resumeWith(Result.success(handleResponseMessage(msg) as T))
}
})
data = Bundle()
bridgeMessage.write(data)
messenger.send(this)
}
}
}
override fun createAndReadFile(
fileType: BridgeFileType,
defaultContent: ByteArray
): ByteArray {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
FileAccessResult::class
).run {
if (state!!) {
return readFile(fileType)
}
writeFile(fileType, defaultContent)
return defaultContent
}
}
override fun readFile(fileType: BridgeFileType): ByteArray {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null),
FileAccessResult::class
).run {
return content!!
}
}
override fun writeFile(
fileType: BridgeFileType,
content: ByteArray?
): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content),
FileAccessResult::class
).run {
return state!!
}
}
override fun deleteFile(fileType: BridgeFileType): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null),
FileAccessResult::class
).run {
return state!!
}
}
override fun isFileExists(fileType: BridgeFileType): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
FileAccessResult::class
).run {
return state!!
}
}
override fun downloadContent(url: String, path: String): Boolean {
sendMessage(
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST,
DownloadContentRequest(url, path),
DownloadContentResult::class
).run {
return state!!
}
}
override fun getLoggedMessageIds(conversationId: String, limit: Int): List<Long> {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.LIST_IDS, conversationId, limit.toLong()),
MessageLoggerListResult::class
).run {
return messages!!
}
}
override fun getMessageLoggerMessage(conversationId: String, id: Long): ByteArray? {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.GET, conversationId, id),
MessageLoggerResult::class
).run {
return message
}
}
override fun addMessageLoggerMessage(conversationId: String,id: Long, message: ByteArray) {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.ADD, conversationId, id, message),
MessageLoggerResult::class
)
}
override fun deleteMessageLoggerMessage(conversationId: String,id: Long) {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.DELETE, conversationId, id),
MessageLoggerResult::class
)
}
override fun clearMessageLogger() {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.CLEAR),
MessageLoggerResult::class
)
}
override fun fetchTranslations(): LocaleResult {
sendMessage(
BridgeMessageType.LOCALE_REQUEST,
LocaleRequest(),
LocaleResult::class
).run {
return this
}
}
override fun getAutoUpdaterTime(): Long {
createAndReadFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, "0".toByteArray()).run {
return if (isEmpty()) {
0
} else {
String(this).toLong()
}
}
}
override fun setAutoUpdaterTime(time: Long) {
writeFile(BridgeFileType.AUTO_UPDATER_TIMESTAMP, time.toString().toByteArray())
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
messenger = Messenger(service)
future.complete(true)
}
override fun onNullBinding(name: ComponentName) {
xposedLog("failed to connect to bridge service")
future.complete(false)
}
override fun onServiceDisconnected(name: ComponentName) {
exitProcess(0)
}
}

View File

@@ -1,16 +0,0 @@
package me.rhunk.snapenhance.bridge.common
import android.os.Bundle
import android.os.Message
abstract class BridgeMessage {
abstract fun write(bundle: Bundle)
abstract fun read(bundle: Bundle)
fun toMessage(what: Int): Message {
val message = Message.obtain(null, what)
message.data = Bundle()
write(message.data)
return message
}
}

View File

@@ -1,23 +0,0 @@
package me.rhunk.snapenhance.bridge.common
enum class BridgeMessageType(
val value: Int = 0
) {
UNKNOWN(-1),
FILE_ACCESS_REQUEST(0),
FILE_ACCESS_RESULT(1),
DOWNLOAD_CONTENT_REQUEST(2),
DOWNLOAD_CONTENT_RESULT(3),
LOCALE_REQUEST(4),
LOCALE_RESULT(5),
MESSAGE_LOGGER_REQUEST(6),
MESSAGE_LOGGER_RESULT(7),
MESSAGE_LOGGER_LIST_RESULT(8);
companion object {
fun fromValue(value: Int): BridgeMessageType {
return values().firstOrNull { it.value == value } ?: UNKNOWN
}
}
}

View File

@@ -1,20 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.download
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class DownloadContentRequest(
var url: String? = null,
var path: String? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putString("url", url)
bundle.putString("path", path)
}
override fun read(bundle: Bundle) {
url = bundle.getString("url")
path = bundle.getString("path")
}
}

View File

@@ -1,17 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.download
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class DownloadContentResult(
var state: Boolean? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
}
}

View File

@@ -1,33 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.file
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class FileAccessRequest(
var action: FileAccessAction? = null,
var fileType: BridgeFileType? = null,
var content: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putInt("action", action!!.value)
bundle.putInt("fileType", fileType!!.value)
bundle.putByteArray("content", content)
}
override fun read(bundle: Bundle) {
action = FileAccessAction.fromValue(bundle.getInt("action"))
fileType = BridgeFileType.fromValue(bundle.getInt("fileType"))
content = bundle.getByteArray("content")
}
enum class FileAccessAction(val value: Int) {
READ(0), WRITE(1), DELETE(2), EXISTS(3);
companion object {
fun fromValue(value: Int): FileAccessAction? {
return values().firstOrNull { it.value == value }
}
}
}
}

View File

@@ -1,20 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.file
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class FileAccessResult(
var state: Boolean? = null,
var content: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
bundle.putByteArray("content", content)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
content = bundle.getByteArray("content")
}
}

View File

@@ -1,12 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.locale
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class LocaleRequest() : BridgeMessage() {
override fun write(bundle: Bundle) {
}
override fun read(bundle: Bundle) {
}
}

View File

@@ -1,31 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.locale
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
import me.rhunk.snapenhance.data.LocalePair
class LocaleResult(
private var locales: Array<String>? = null,
private var localContentArray: Array<String>? = null
) : BridgeMessage(){
fun getLocales(): List<LocalePair> {
val locales = locales ?: return emptyList()
val localContentArray = localContentArray ?: return emptyList()
return locales.mapIndexed { index, locale ->
LocalePair(locale, localContentArray[index])
}.asReversed()
}
override fun write(bundle: Bundle) {
bundle.putStringArray("locales", locales)
bundle.putSerializable("localContentArray", localContentArray)
}
@Suppress("UNCHECKED_CAST", "DEPRECATION")
override fun read(bundle: Bundle) {
locales = bundle.getStringArray("locales")
localContentArray = bundle.getSerializable("localContentArray") as? Array<String>
}
}

View File

@@ -1,18 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.messagelogger
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class MessageLoggerListResult(
var messages: List<Long>? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putLongArray("messages", messages!!.map { it }.toLongArray())
}
override fun read(bundle: Bundle) {
messages = bundle.getLongArray("messages")?.toList() ?: emptyList()
}
}

View File

@@ -1,34 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.messagelogger
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class MessageLoggerRequest(
var action: Action? = null,
var conversationId: String? = null,
var index: Long? = null,
var message: ByteArray? = null
) : BridgeMessage(){
override fun write(bundle: Bundle) {
bundle.putString("action", action!!.name)
bundle.putString("conversationId", conversationId)
bundle.putLong("messageId", index ?: 0)
bundle.putByteArray("message", message)
}
override fun read(bundle: Bundle) {
action = Action.valueOf(bundle.getString("action")!!)
conversationId = bundle.getString("conversationId")
index = bundle.getLong("messageId")
message = bundle.getByteArray("message")
}
enum class Action {
ADD,
GET,
CLEAR,
DELETE,
LIST_IDS
}
}

View File

@@ -1,20 +0,0 @@
package me.rhunk.snapenhance.bridge.common.impl.messagelogger
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class MessageLoggerResult(
var state: Boolean? = null,
var message: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
bundle.putByteArray("message", message)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
message = bundle.getByteArray("message")
}
}

View File

@@ -1,189 +0,0 @@
package me.rhunk.snapenhance.bridge.service
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.app.Service
import android.content.*
import android.net.Uri
import android.os.*
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.bridge.MessageLoggerWrapper
import me.rhunk.snapenhance.bridge.TranslationWrapper
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
import me.rhunk.snapenhance.bridge.common.impl.*
import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentRequest
import me.rhunk.snapenhance.bridge.common.impl.download.DownloadContentResult
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessRequest
import me.rhunk.snapenhance.bridge.common.impl.file.FileAccessResult
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleRequest
import me.rhunk.snapenhance.bridge.common.impl.locale.LocaleResult
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerListResult
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerRequest
import me.rhunk.snapenhance.bridge.common.impl.messagelogger.MessageLoggerResult
import java.io.File
import java.util.*
class BridgeService : Service() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
override fun onBind(intent: Intent): IBinder {
messageLoggerWrapper = MessageLoggerWrapper(getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)).also { it.init() }
return Messenger(object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
runCatching {
this@BridgeService.handleMessage(msg)
}.onFailure {
Logger.error("Failed to handle message ${BridgeMessageType.fromValue(msg.what)}", it)
}
}
}).binder
}
private fun handleMessage(msg: Message) {
val replyMessenger = msg.replyTo
when (BridgeMessageType.fromValue(msg.what)) {
BridgeMessageType.FILE_ACCESS_REQUEST -> {
with(FileAccessRequest()) {
read(msg.data)
handleFileAccess(this) { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST -> {
with(DownloadContentRequest()) {
read(msg.data)
handleDownloadContent(this) { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.LOCALE_REQUEST -> {
with(LocaleRequest()) {
read(msg.data)
handleLocaleRequest { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.MESSAGE_LOGGER_REQUEST -> {
with(MessageLoggerRequest()) {
read(msg.data)
handleMessageLoggerRequest(this) { message ->
replyMessenger.send(message)
}
}
}
else -> Logger.log("Unknown message type: " + msg.what)
}
}
private fun handleMessageLoggerRequest(msg: MessageLoggerRequest, reply: (Message) -> Unit) {
when (msg.action) {
MessageLoggerRequest.Action.ADD -> {
val isSuccess = messageLoggerWrapper.addMessage(msg.conversationId!!, msg.index!!, msg.message!!)
reply(MessageLoggerResult(isSuccess).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
return
}
MessageLoggerRequest.Action.CLEAR -> {
messageLoggerWrapper.clearMessages()
}
MessageLoggerRequest.Action.DELETE -> {
messageLoggerWrapper.deleteMessage(msg.conversationId!!, msg.index!!)
}
MessageLoggerRequest.Action.GET -> {
val (state, messageData) = messageLoggerWrapper.getMessage(msg.conversationId!!, msg.index!!)
reply(MessageLoggerResult(state, messageData).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
return
}
MessageLoggerRequest.Action.LIST_IDS -> {
val messageIds = messageLoggerWrapper.getMessageIds(msg.conversationId!!, msg.index!!.toInt())
reply(MessageLoggerListResult(messageIds).toMessage(BridgeMessageType.MESSAGE_LOGGER_LIST_RESULT.value))
return
}
else -> {
Logger.log(Exception("Unknown message logger action: ${msg.action}"))
}
}
reply(MessageLoggerResult(true).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
}
private fun handleLocaleRequest(reply: (Message) -> Unit) {
val locales = sortedSetOf<String>()
val contentArray = sortedSetOf<String>()
TranslationWrapper.fetchLocales(context = this).forEach { pair ->
locales.add(pair.locale)
contentArray.add(pair.content)
}
reply(LocaleResult(locales.toTypedArray(), contentArray.toTypedArray()).toMessage(BridgeMessageType.LOCALE_RESULT.value))
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun handleDownloadContent(msg: DownloadContentRequest, reply: (Message) -> Unit) {
if (!msg.url!!.startsWith("http://127.0.0.1:")) return
val outputFile = File(msg.path!!)
outputFile.parentFile?.let {
if (!it.exists()) it.mkdirs()
}
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(msg.url))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setDestinationUri(Uri.fromFile(outputFile))
val downloadId = downloadManager.enqueue(request)
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
unregisterReceiver(this)
reply(DownloadContentResult(true).toMessage(BridgeMessageType.DOWNLOAD_CONTENT_RESULT.value))
}
}, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
private fun handleFileAccess(msg: FileAccessRequest, reply: (Message) -> Unit) {
val fileFolder = if (msg.fileType!!.isDatabase) {
File(dataDir, "databases")
} else {
File(filesDir.absolutePath)
}
val requestFile = File(fileFolder, msg.fileType!!.fileName)
val result: FileAccessResult = when (msg.action) {
FileAccessRequest.FileAccessAction.READ -> {
if (!requestFile.exists()) {
FileAccessResult(false, null)
} else {
FileAccessResult(true, requestFile.readBytes())
}
}
FileAccessRequest.FileAccessAction.WRITE -> {
if (!requestFile.exists()) {
requestFile.createNewFile()
}
requestFile.writeBytes(msg.content!!)
FileAccessResult(true, null)
}
FileAccessRequest.FileAccessAction.DELETE -> {
if (!requestFile.exists()) {
FileAccessResult(false, null)
} else {
requestFile.delete()
FileAccessResult(true, null)
}
}
FileAccessRequest.FileAccessAction.EXISTS -> FileAccessResult(requestFile.exists(), null)
else -> throw Exception("Unknown action: " + msg.action)
}
reply(result.toMessage(BridgeMessageType.FILE_ACCESS_RESULT.value))
}
}

View File

@@ -1,10 +1,10 @@
package me.rhunk.snapenhance.bridge.common.impl.file
package me.rhunk.snapenhance.bridge.types
import android.content.Context
import java.io.File
enum class BridgeFileType(val value: Int, val fileName: String, val displayName: String, val isDatabase: Boolean = false) {
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),

View File

@@ -1,10 +1,11 @@
package me.rhunk.snapenhance.bridge
package me.rhunk.snapenhance.bridge.wrapper
import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.config.ConfigAccessor
import me.rhunk.snapenhance.config.ConfigProperty
@@ -70,7 +71,7 @@ class ConfigWrapper: ConfigAccessor() {
load()
}
fun loadFromBridge(bridgeClient: AbstractBridgeClient) {
fun loadFromBridge(bridgeClient: BridgeClient) {
isFileExistsAction = { bridgeClient.isFileExists(BridgeFileType.CONFIG) }
readFileAction = { bridgeClient.createAndReadFile(BridgeFileType.CONFIG, "{}".toByteArray(Charsets.UTF_8)) }
writeFileAction = { bridgeClient.writeFile(BridgeFileType.CONFIG, it) }

View File

@@ -1,4 +1,4 @@
package me.rhunk.snapenhance.bridge
package me.rhunk.snapenhance.bridge.wrapper
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase

View File

@@ -1,9 +1,10 @@
package me.rhunk.snapenhance.bridge
package me.rhunk.snapenhance.bridge.wrapper
import android.content.Context
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.data.LocalePair
import java.util.Locale
@@ -62,8 +63,8 @@ class TranslationWrapper {
scanObject(translations)
}
fun loadFromBridge(bridgeClient: AbstractBridgeClient) {
bridgeClient.fetchTranslations().getLocales().forEach {
fun loadFromBridge(bridgeClient: BridgeClient) {
bridgeClient.fetchTranslations().forEach {
load(it)
}
}

View File

@@ -1,12 +1,14 @@
package me.rhunk.snapenhance.config
enum class ConfigCategory(
val key: String
val key: String,
val hidden: Boolean = false
) {
SPYING_PRIVACY("spying_privacy"),
MEDIA_MANAGEMENT("media_manager"),
UI_TWEAKS("ui_tweaks"),
UPDATES("updates"),
CAMERA("camera"),
EXPERIMENTAL_DEBUGGING("experimental_debugging");
EXPERIMENTAL_DEBUGGING("experimental_debugging"),
DEVICE_SPOOFER("device_spoofer", hidden = true)
}

View File

@@ -1,18 +1,18 @@
package me.rhunk.snapenhance.config
import android.os.Environment
import me.rhunk.snapenhance.config.impl.ConfigIntegerValue
import me.rhunk.snapenhance.config.impl.ConfigStateListValue
import me.rhunk.snapenhance.config.impl.ConfigStateSelection
import me.rhunk.snapenhance.config.impl.ConfigStateValue
import me.rhunk.snapenhance.config.impl.ConfigStringValue
import me.rhunk.snapenhance.data.NotificationType
import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks
import java.io.File
enum class ConfigProperty(
val translationKey: String,
val category: ConfigCategory,
val valueContainer: ConfigValue<*>,
val valueContainerTranslationKey: String? = null,
val shouldAppearInSettings: Boolean = true,
val disableValueLocalization: Boolean = false
) {
@@ -36,11 +36,12 @@ enum class ConfigProperty(
"better_notifications",
ConfigCategory.SPYING_PRIVACY,
ConfigStateListValue(
listOf("snap", "chat", "reply_button"),
listOf("snap", "chat", "reply_button", "download_button"),
mutableMapOf(
"snap" to false,
"chat" to false,
"reply_button" to false
"reply_button" to false,
"download_button" to false
)
)
),
@@ -48,13 +49,10 @@ enum class ConfigProperty(
"notification_blacklist",
ConfigCategory.SPYING_PRIVACY,
ConfigStateListValue(
listOf("snap", "chat", "typing"),
mutableMapOf(
"snap" to false,
"chat" to false,
"typing" to false
)
)
NotificationType.getIncomingValues().map { it.key },
NotificationType.getIncomingValues().associate { it.key to false }.toMutableMap()
),
valueContainerTranslationKey = "notifications",
),
DISABLE_METRICS("disable_metrics",
ConfigCategory.SPYING_PRIVACY,
@@ -68,15 +66,14 @@ enum class ConfigProperty(
ConfigCategory.SPYING_PRIVACY,
ConfigStateValue(false)
),
PREVENT_SCREENSHOT_NOTIFICATIONS(
"prevent_screenshot_notifications",
PREVENT_SENDING_MESSAGES(
"prevent_sending_messages",
ConfigCategory.SPYING_PRIVACY,
ConfigStateValue(false)
),
PREVENT_STATUS_NOTIFICATIONS(
"prevent_status_notifications",
ConfigCategory.SPYING_PRIVACY,
ConfigStateValue(false)
ConfigStateListValue(
NotificationType.getOutgoingValues().map { it.key },
NotificationType.getOutgoingValues().associate { it.key to false }.toMutableMap()
),
valueContainerTranslationKey = "notifications",
),
ANONYMOUS_STORY_VIEW(
"anonymous_story_view",
@@ -93,10 +90,7 @@ enum class ConfigProperty(
SAVE_FOLDER(
"save_folder",
ConfigCategory.MEDIA_MANAGEMENT,
ConfigStringValue(File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat",
"SnapEnhance"
).absolutePath)
ConfigStringValue("", isFolderPath =true),
),
AUTO_DOWNLOAD_OPTIONS(
"auto_download_options",
@@ -160,6 +154,19 @@ enum class ConfigProperty(
ConfigCategory.MEDIA_MANAGEMENT,
ConfigStateValue(false)
),
DOWNLOAD_LOGGING(
"download_logging",
ConfigCategory.MEDIA_MANAGEMENT,
ConfigStateListValue(
listOf("started", "success", "progress", "failure"),
mutableMapOf(
"started" to false,
"success" to true,
"progress" to false,
"failure" to true
)
)
),
//UI AND TWEAKS
ENABLE_FRIEND_FEED_MENU_BAR(
@@ -203,8 +210,9 @@ enum class ConfigProperty(
"hide_story_section",
ConfigCategory.UI_TWEAKS,
ConfigStateListValue(
listOf("hide_friends", "hide_following", "hide_for_you"),
listOf("hide_friend_suggestions", "hide_friends", "hide_following", "hide_for_you"),
mutableMapOf(
"hide_friend_suggestions" to false,
"hide_friends" to false,
"hide_following" to false,
"hide_for_you" to false
@@ -294,7 +302,11 @@ enum class ConfigProperty(
"OFF"
)
),
DISABLE_GOOGLE_PLAY_DIALOGS(
"disable_google_play_dialogs",
ConfigCategory.UI_TWEAKS,
ConfigStateValue(false)
),
//CAMERA
CAMERA_DISABLE(
@@ -376,8 +388,27 @@ enum class ConfigProperty(
"unlimited_multi_snap",
ConfigCategory.EXPERIMENTAL_DEBUGGING,
ConfigStateValue(false)
),
//DEVICE SPOOFER
DEVICE_SPOOF(
"device_spoof",
ConfigCategory.DEVICE_SPOOFER,
ConfigStateValue(false)
),
FINGERPRINT(
"device_fingerprint",
ConfigCategory.DEVICE_SPOOFER,
ConfigStringValue("")
),
ANDROID_ID(
"android_id",
ConfigCategory.DEVICE_SPOOFER,
ConfigStringValue("")
);
fun getOptionTranslationKey(key: String) = "option.property.${valueContainerTranslationKey ?: translationKey}.$key"
companion object {
fun sortedByCategory(): List<ConfigProperty> {
return values().sortedBy { it.category.ordinal }

View File

@@ -4,6 +4,7 @@ import me.rhunk.snapenhance.config.ConfigValue
class ConfigStringValue(
private var value: String = "",
val isFolderPath: Boolean = false,
val isHidden: Boolean = false
) : ConfigValue<String>() {
override fun value() = value

View File

@@ -1,6 +1,7 @@
package me.rhunk.snapenhance.data
import java.io.File
import java.io.InputStream
enum class FileType(
val fileExtension: String? = null,
@@ -58,5 +59,11 @@ enum class FileType(
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
}
fun fromInputStream(inputStream: InputStream): FileType {
val buffer = ByteArray(16)
inputStream.read(buffer)
return fromByteArray(buffer)
}
}
}

View File

@@ -4,39 +4,39 @@ enum class MessageState {
PREPARING, SENDING, COMMITTED, FAILED, CANCELING
}
enum class ChatMediaType (
val value: Int
enum class NotificationType (
val key: String,
val isIncoming: Boolean = false,
val associatedOutgoingContentType: ContentType? = null,
) {
IMAGE(0),
VIDEO(1),
VIDEO_NO_SOUND(2),
FRIEND_DEPRECATED(3),
BLOB(4),
LAGUNA_SOUND(5),
LAGUNA_NO_SOUND(6),
GIF(7),
FINGERPRINT_HEADER_SIZE(8),
AUDIO_STITCH(9),
PSYCHOMANTIS(10),
SCREAMINGMANTIS(11),
MALIBU_SOUND(12),
MALIBU_NO_SOUND(13),
LAGUNAHD_SOUND(14),
LAGUNAHD_NO_SOUND(15),
GHOSTMANTIS(16),
NEWPORT_SOUND(17),
NEWPORT_NO_SOUND(18),
AUDIO(19),
BLOOP(20),
SPECTACLES_IMAGE(21),
SPECTACLES_VIDEO(22),
SPECTACLES_VIDEO_NO_SOUND(23),
CHEERIOS_IMAGE(24),
CHEERIOS_VIDEO_SOUND(25),
CHEERIOS_VIDEO_NO_SOUND(26),
WEB(27),
UNRECOGNIZED_VALUE(-9999);
SCREENSHOT("chat_screenshot", true, ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT),
SCREEN_RECORD("chat_screen_record", true, ContentType.STATUS_CONVERSATION_CAPTURE_RECORD),
CAMERA_ROLL_SAVE("camera_roll_save", true, ContentType.STATUS_SAVE_TO_CAMERA_ROLL),
SNAP("snap",true),
CHAT("chat",true),
CHAT_REPLY("chat_reply",true),
TYPING("typing", true),
STORIES("stories",true),
INITIATE_AUDIO("initiate_audio",true),
ABANDON_AUDIO("abandon_audio", false, ContentType.STATUS_CALL_MISSED_AUDIO),
INITIATE_VIDEO("initiate_video",true),
ABANDON_VIDEO("abandon_video", false, ContentType.STATUS_CALL_MISSED_VIDEO);
companion object {
fun getIncomingValues(): List<NotificationType> {
return values().filter { it.isIncoming }.toList()
}
fun getOutgoingValues(): List<NotificationType> {
return values().filter { it.associatedOutgoingContentType != null }.toList()
}
fun fromContentType(contentType: ContentType): NotificationType? {
return values().firstOrNull { it.associatedOutgoingContentType == contentType }
}
}
}
enum class ContentType(val id: Int) {
UNKNOWN(-1),
SNAP(0),
@@ -56,7 +56,8 @@ enum class ContentType(val id: Int) {
CREATIVE_TOOL_ITEM(14),
FAMILY_CENTER_INVITE(15),
FAMILY_CENTER_ACCEPT(16),
FAMILY_CENTER_LEAVE(17);
FAMILY_CENTER_LEAVE(17),
STATUS_PLUS_GIFT(18);
companion object {
fun fromId(i: Int): ContentType {

View File

@@ -4,6 +4,7 @@ import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.setObjectField
@Suppress("UNCHECKED_CAST")
class MessageDestinations(obj: Any) : AbstractWrapper(obj){
var conversations get() = (instanceNonNull().getObjectField("mConversations") as ArrayList<*>).map { SnapUUID(it) }
set(value) = instanceNonNull().setObjectField("mConversations", value.map { it.instanceNonNull() }.toCollection(ArrayList()))

View File

@@ -2,83 +2,63 @@ package me.rhunk.snapenhance.download
import android.content.Intent
import android.os.Bundle
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.download.data.DashOptions
import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.DownloadRequest
import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
import me.rhunk.snapenhance.download.enums.DownloadMediaType
class DownloadManagerClient (
private val context: ModContext,
private val outputPath: String,
private val mediaDisplaySource: String?,
private val mediaDisplayType: String?,
private val iconUrl: String?,
private val uniqueHash: String?
private val metadata: DownloadMetadata,
private val callback: DownloadCallback
) {
private fun sendToBroadcastReceiver(bundle: Bundle) {
val intent = Intent()
intent.setClassName(BuildConfig.APPLICATION_ID, DownloadManagerReceiver::class.java.name)
intent.action = DownloadManagerReceiver.DOWNLOAD_ACTION
intent.putExtras(bundle)
context.androidContext.sendBroadcast(intent)
}
private fun sendToBroadcastReceiver(
request: DownloadRequest,
extras: Bundle.() -> Unit = {}
) {
sendToBroadcastReceiver(request.toBundle().apply {
putString("outputPath", outputPath)
putString("mediaDisplaySource", mediaDisplaySource)
putString("mediaDisplayType", mediaDisplayType)
putString("iconUrl", iconUrl)
putString("uniqueHash", uniqueHash)
}.apply(extras))
private fun enqueueDownloadRequest(request: DownloadRequest) {
context.bridgeClient.enqueueDownload(Intent().apply {
putExtras(Bundle().apply {
putString(DownloadProcessor.DOWNLOAD_REQUEST_EXTRA, context.gson.toJson(request))
putString(DownloadProcessor.DOWNLOAD_METADATA_EXTRA, context.gson.toJson(metadata))
})
}, callback)
}
fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long?) {
sendToBroadcastReceiver(
enqueueDownloadRequest(
DownloadRequest(
inputMedias = arrayOf(playlistUrl),
inputTypes = arrayOf(DownloadMediaType.REMOTE_MEDIA.name),
inputMedias = arrayOf(InputMedia(
content = playlistUrl,
type = DownloadMediaType.REMOTE_MEDIA
)),
dashOptions = DashOptions(offsetTime, duration),
flags = DownloadRequest.Flags.IS_DASH_PLAYLIST
)
) {
putBundle("dashOptions", Bundle().apply {
putString("offsetTime", offsetTime.toString())
duration?.let { putString("duration", it.toString()) }
})
}
)
}
fun downloadMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) {
sendToBroadcastReceiver(
fun downloadSingleMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) {
enqueueDownloadRequest(
DownloadRequest(
inputMedias = arrayOf(mediaData),
inputTypes = arrayOf(mediaType.name),
mediaEncryption = if (encryption != null) mapOf(mediaData to encryption) else mapOf()
inputMedias = arrayOf(InputMedia(
content = mediaData,
type = mediaType,
encryption = encryption
))
)
)
}
fun downloadMediaWithOverlay(
videoData: String,
overlayData: String,
videoType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA,
overlayType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA,
videoEncryption: MediaEncryptionKeyPair? = null,
overlayEncryption: MediaEncryptionKeyPair? = null)
{
val encryptionMap = mutableMapOf<String, MediaEncryptionKeyPair>()
if (videoEncryption != null) encryptionMap[videoData] = videoEncryption
if (overlayEncryption != null) encryptionMap[overlayData] = overlayEncryption
sendToBroadcastReceiver(DownloadRequest(
inputMedias = arrayOf(videoData, overlayData),
inputTypes = arrayOf(videoType.name, overlayType.name),
mediaEncryption = encryptionMap,
flags = DownloadRequest.Flags.SHOULD_MERGE_OVERLAY
))
original: InputMedia,
overlay: InputMedia,
) {
enqueueDownloadRequest(
DownloadRequest(
inputMedias = arrayOf(original, overlay),
flags = DownloadRequest.Flags.MERGE_OVERLAY
)
)
}
}

View File

@@ -1,12 +1,12 @@
package me.rhunk.snapenhance.download
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -17,7 +17,11 @@ import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.bridge.wrapper.ConfigWrapper
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.DownloadRequest
import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair
@@ -49,33 +53,29 @@ data class DownloadedFile(
)
/**
* DownloadManagerReceiver handles the download requests of the user
* DownloadProcessor handles the download requests of the user
*/
@OptIn(ExperimentalEncodingApi::class)
class DownloadManagerReceiver : BroadcastReceiver() {
class DownloadProcessor (
private val context: Context,
private val callback: DownloadCallback
) {
companion object {
const val DOWNLOAD_ACTION = "me.rhunk.snapenhance.download.DownloadManagerReceiver.DOWNLOAD_ACTION"
const val DOWNLOAD_REQUEST_EXTRA = "request"
const val DOWNLOAD_METADATA_EXTRA = "metadata"
}
private val translation by lazy {
SharedContext.translation.getCategory("download_manager_receiver")
SharedContext.translation.getCategory("download_processor")
}
private lateinit var context: Context
private fun runOnUIThread(block: () -> Unit) {
Handler(context.mainLooper).post(block)
private val gson by lazy {
GsonBuilder().setPrettyPrinting().create()
}
private fun shortToast(text: String) {
runOnUIThread {
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
}
private fun longToast(text: String) {
runOnUIThread {
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
private fun fallbackToast(message: Any) {
android.os.Handler(context.mainLooper).post {
Toast.makeText(context, message.toString(), Toast.LENGTH_SHORT).show()
}
}
@@ -117,42 +117,58 @@ class DownloadManagerReceiver : BroadcastReceiver() {
private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) {
if (coroutineContext.job.isCancelled) return
val config = ConfigWrapper().apply { loadFromContext(context) }
runCatching {
val fileType = FileType.fromFile(inputFile)
if (fileType == FileType.UNKNOWN) {
longToast(translation.format("failed_gallery_toast", "error" to "Unknown media type"))
callback.onFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null)
return
}
val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) }
inputFile.copyTo(outputFile, overwrite = true)
val fileName = pendingDownload.metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
pendingDownload.outputFile = outputFile.absolutePath
val outputFolder = DocumentFile.fromTreeUri(context, Uri.parse(config.string(ConfigProperty.SAVE_FOLDER)))
?: throw Exception("Failed to open output folder")
val outputFileFolder = pendingDownload.metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!!
}
} else {
outputFolder
}
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
val outputStream = context.contentResolver.openOutputStream(outputFile.uri)!!
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
pendingDownload.outputFile = outputFile.uri.toString()
pendingDownload.downloadStage = DownloadStage.SAVED
runCatching {
val contentUri = Uri.fromFile(outputFile)
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE")
mediaScanIntent.setData(contentUri)
mediaScanIntent.setData(outputFile.uri)
context.sendBroadcast(mediaScanIntent)
}.onFailure {
Logger.error("Failed to scan media file", it)
longToast(translation.format("failed_gallery_toast", "error" to it.toString()))
callback.onFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message)
}
Logger.debug("download complete")
//print the path of the saved media
val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let {
if (!it.endsWith("/")) "$it/" else it
fileName.let {
runCatching { callback.onSuccess(it) }.onFailure { fallbackToast(it) }
}
}.onFailure { exception ->
Logger.error(exception)
translation.format("failed_gallery_toast", "error" to exception.toString()).let {
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
shortToast(
translation.format("saved_toast", "path" to outputFile.absolutePath.replace(parentName ?: "", ""))
)
}.onFailure {
Logger.error(it)
longToast(translation.format("failed_gallery_toast", "error" to it.toString()))
pendingDownload.downloadStage = DownloadStage.FAILED
}
}
@@ -165,7 +181,7 @@ class DownloadManagerReceiver : BroadcastReceiver() {
val jobs = mutableListOf<Job>()
val downloadedMedias = mutableMapOf<InputMedia, File>()
downloadRequest.getInputMedias().forEach { inputMedia ->
downloadRequest.inputMedias.forEach { inputMedia ->
fun handleInputStream(inputStream: InputStream) {
createMediaTempFile().apply {
if (inputMedia.encryption != null) {
@@ -210,9 +226,9 @@ class DownloadManagerReceiver : BroadcastReceiver() {
downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
downloadRequest.getInputMedias().first().let { inputMedia ->
val mediaType = downloadRequest.getInputType(0)!!
private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
if (!downloadRequest.isDashPlaylist) {
@@ -231,13 +247,15 @@ class DownloadManagerReceiver : BroadcastReceiver() {
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
}
val dashOptions = downloadRequest.getDashOptions()!!
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val xmlData = dashPlaylistFile.outputStream()
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData))
longToast(translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension))
translation.format("download_toast", "path" to dashPlaylistFile.nameWithoutExtension).let {
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
}
val outputFile = File.createTempFile("dash", ".mp4")
runCatching {
MediaDownloaderHelper.downloadDashChapterFile(
@@ -246,10 +264,12 @@ class DownloadManagerReceiver : BroadcastReceiver() {
startTime = dashOptions.offsetTime,
duration = dashOptions.duration)
saveMediaToGallery(outputFile, pendingDownloadObject)
}.onFailure {
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(it)
longToast(translation.format("failed_processing_toast", "error" to it.toString()))
Logger.error(exception)
translation.format("failed_processing_toast", "error" to exception.toString()).let {
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.FAILED
}
@@ -265,28 +285,25 @@ class DownloadManagerReceiver : BroadcastReceiver() {
return newFile
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != DOWNLOAD_ACTION) return
this.context = context
Logger.debug("onReceive download")
fun onReceive(intent: Intent) {
CoroutineScope(Dispatchers.IO).launch {
val downloadMetadata = gson.fromJson(intent.getStringExtra(DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
SharedContext.ensureInitialized(context)
val downloadRequest = DownloadRequest.fromBundle(intent.extras!!)
SharedContext.downloadTaskManager.canDownloadMedia(downloadRequest.getUniqueHash())?.let { downloadStage ->
shortToast(
SharedContext.downloadTaskManager.canDownloadMedia(downloadMetadata.mediaIdentifier)?.let { downloadStage ->
translation[if (downloadStage.isFinalStage) {
"already_downloaded_toast"
} else {
"already_queued_toast"
}]
)
return
}
}].let {
runCatching { callback.onFailure(it, null) }.onFailure { fallbackToast(it) }
}
return@launch
}
CoroutineScope(Dispatchers.IO).launch {
val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!)
val pendingDownloadObject = PendingDownload(
metadata = downloadMetadata
)
SharedContext.downloadTaskManager.addTask(pendingDownloadObject)
pendingDownloadObject.apply {
@@ -330,7 +347,9 @@ class DownloadManagerReceiver : BroadcastReceiver() {
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension)
runCatching {
longToast(translation.format("download_toast", "path" to media.file.nameWithoutExtension))
translation.format("download_toast", "path" to media.file.nameWithoutExtension).let {
runCatching { callback.onProgress(it) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.MERGING
MediaDownloaderHelper.mergeOverlayFile(
@@ -340,10 +359,12 @@ class DownloadManagerReceiver : BroadcastReceiver() {
)
saveMediaToGallery(mergedOverlay, pendingDownloadObject)
}.onFailure {
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
Logger.error(it)
longToast(translation.format("failed_processing_toast", "error" to it.toString()))
Logger.error(exception)
translation.format("failed_processing_toast", "error" to exception.toString()).let {
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED
}
@@ -354,10 +375,12 @@ class DownloadManagerReceiver : BroadcastReceiver() {
}
downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest)
}.onFailure {
}.onFailure { exception ->
pendingDownloadObject.downloadStage = DownloadStage.FAILED
Logger.error(it)
longToast(translation["failed_generic_toast"])
Logger.error(exception)
translation["failed_generic_toast"].let {
runCatching { callback.onFailure(it, exception.message) }.onFailure { fallbackToast(it) }
}
}
}
}

View File

@@ -3,6 +3,7 @@ package me.rhunk.snapenhance.download
import android.annotation.SuppressLint
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadStage
import me.rhunk.snapenhance.ui.download.MediaFilter
@@ -35,42 +36,42 @@ class DownloadTaskManager {
fun addTask(task: PendingDownload): Int {
taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)",
arrayOf(
task.uniqueHash,
task.outputPath,
task.metadata.mediaIdentifier,
task.metadata.outputPath,
task.outputFile,
task.mediaDisplayType,
task.mediaDisplaySource,
task.iconUrl,
task.metadata.mediaDisplayType,
task.metadata.mediaDisplaySource,
task.metadata.iconUrl,
task.downloadStage.name
)
)
task.id = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use {
task.downloadId = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use {
it.moveToFirst()
it.getInt(0)
}
pendingTasks[task.id] = task
return task.id
pendingTasks[task.downloadId] = task
return task.downloadId
}
fun updateTask(task: PendingDownload) {
taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?",
arrayOf(
task.uniqueHash,
task.outputPath,
task.metadata.mediaIdentifier,
task.metadata.outputPath,
task.outputFile,
task.mediaDisplayType,
task.mediaDisplaySource,
task.iconUrl,
task.metadata.mediaDisplayType,
task.metadata.mediaDisplaySource,
task.metadata.iconUrl,
task.downloadStage.name,
task.id
task.downloadId
)
)
//if the task is no longer active, move it to the cached tasks
if (task.isJobActive()) {
pendingTasks[task.id] = task
pendingTasks[task.downloadId] = task
} else {
pendingTasks.remove(task.id)
cachedTasks[task.id] = task
pendingTasks.remove(task.downloadId)
cachedTasks[task.downloadId] = task
}
}
@@ -107,20 +108,20 @@ class DownloadTaskManager {
}
fun removeTask(task: PendingDownload) {
removeTask(task.id)
removeTask(task.downloadId)
}
fun queryAllTasks(filter: MediaFilter): Map<Int, PendingDownload> {
val isPendingFilter = filter == MediaFilter.PENDING
val tasks = mutableMapOf<Int, PendingDownload>()
tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.mediaDisplayType) })
tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) })
if (isPendingFilter) {
return tasks.toSortedMap(reverseOrder())
}
tasks.putAll(queryTasks(
from = tasks.values.lastOrNull()?.id ?: Int.MAX_VALUE,
from = tasks.values.lastOrNull()?.downloadId ?: Int.MAX_VALUE,
amount = 30,
filter = filter
))
@@ -147,13 +148,15 @@ class DownloadTaskManager {
while (cursor.moveToNext()) {
val task = PendingDownload(
id = cursor.getInt(cursor.getColumnIndex("id")),
downloadId = cursor.getInt(cursor.getColumnIndex("id")),
outputFile = cursor.getString(cursor.getColumnIndex("outputFile")),
outputPath = cursor.getString(cursor.getColumnIndex("outputPath")),
mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")),
mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")),
uniqueHash = cursor.getString(cursor.getColumnIndex("hash")),
iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl"))
metadata = DownloadMetadata(
outputPath = cursor.getString(cursor.getColumnIndex("outputPath")),
mediaIdentifier = cursor.getString(cursor.getColumnIndex("hash")),
mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")),
mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")),
iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl"))
)
).apply {
downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage")))
//if downloadStage is not saved, it means the app was killed before the download was finished
@@ -161,7 +164,7 @@ class DownloadTaskManager {
downloadStage = DownloadStage.FAILED
}
}
result[task.id] = task
result[task.downloadId] = task
}
cursor.close()

View File

@@ -0,0 +1,9 @@
package me.rhunk.snapenhance.download.data
data class DownloadMetadata(
val mediaIdentifier: String?,
val outputPath: String,
val mediaDisplaySource: String?,
val mediaDisplayType: String?,
val iconUrl: String?
)

View File

@@ -1,6 +1,5 @@
package me.rhunk.snapenhance.download.data
import android.os.Bundle
import me.rhunk.snapenhance.download.enums.DownloadMediaType
@@ -12,64 +11,12 @@ data class InputMedia(
)
class DownloadRequest(
private val outputPath: String = "",
private val inputMedias: Array<String>,
private val inputTypes: Array<String>,
private val mediaEncryption: Map<String, MediaEncryptionKeyPair> = emptyMap(),
val inputMedias: Array<InputMedia>,
val dashOptions: DashOptions? = null,
private val flags: Int = 0,
private val dashOptions: Map<String, String?>? = null,
private val mediaDisplaySource: String? = null,
private val mediaDisplayType: String? = null,
private val uniqueHash: String? = null
) {
companion object {
fun fromBundle(bundle: Bundle): DownloadRequest {
return DownloadRequest(
outputPath = bundle.getString("outputPath")!!,
mediaDisplaySource = bundle.getString("mediaDisplaySource"),
mediaDisplayType = bundle.getString("mediaDisplayType"),
inputMedias = bundle.getStringArray("inputMedias")!!,
inputTypes = bundle.getStringArray("inputTypes")!!,
mediaEncryption = bundle.getStringArray("mediaEncryption")?.associate { entry ->
entry.split("|").let {
it[0] to MediaEncryptionKeyPair(it[1], it[2])
}
} ?: emptyMap(),
dashOptions = bundle.getBundle("dashOptions")?.let { options ->
options.keySet().associateWith { key ->
options.getString(key)
}
},
flags = bundle.getInt("flags", 0),
uniqueHash = bundle.getString("uniqueHash")
)
}
}
fun toBundle(): Bundle {
return Bundle().apply {
putString("outputPath", outputPath)
putString("mediaDisplaySource", mediaDisplaySource)
putString("mediaDisplayType", mediaDisplayType)
putStringArray("inputMedias", inputMedias)
putStringArray("inputTypes", inputTypes)
putStringArray("mediaEncryption", mediaEncryption.map { entry ->
"${entry.key}|${entry.value.key}|${entry.value.iv}"
}.toTypedArray())
putBundle("dashOptions", dashOptions?.let { bundle ->
Bundle().apply {
bundle.forEach { (key, value) ->
putString(key, value)
}
}
})
putInt("flags", flags)
putString("uniqueHash", uniqueHash)
}
}
object Flags {
const val SHOULD_MERGE_OVERLAY = 1
const val MERGE_OVERLAY = 1
const val IS_DASH_PLAYLIST = 2
}
@@ -77,30 +24,5 @@ class DownloadRequest(
get() = flags and Flags.IS_DASH_PLAYLIST != 0
val shouldMergeOverlay: Boolean
get() = flags and Flags.SHOULD_MERGE_OVERLAY != 0
fun getDashOptions(): DashOptions? {
return dashOptions?.let {
DashOptions(
offsetTime = it["offsetTime"]?.toLong() ?: 0,
duration = it["duration"]?.toLong()
)
}
}
fun getInputMedias(): List<InputMedia> {
return inputMedias.mapIndexed { index, uri ->
InputMedia(
content = uri,
type = DownloadMediaType.valueOf(inputTypes[index]),
encryption = mediaEncryption.getOrDefault(uri, null)
)
}
}
fun getInputType(index: Int): DownloadMediaType? {
return inputTypes.getOrNull(index)?.let { DownloadMediaType.valueOf(it) }
}
fun getUniqueHash() = uniqueHash
get() = flags and Flags.MERGE_OVERLAY != 0
}

View File

@@ -1,33 +1,16 @@
package me.rhunk.snapenhance.download.data
import android.os.Bundle
import kotlinx.coroutines.Job
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.download.enums.DownloadStage
data class PendingDownload(
var downloadId: Int = 0,
var outputFile: String? = null,
var job: Job? = null,
var id: Int = 0,
val outputPath: String,
val mediaDisplayType: String?,
val mediaDisplaySource: String?,
val iconUrl: String?,
val uniqueHash: String?
val metadata : DownloadMetadata
) {
companion object {
fun fromBundle(bundle: Bundle): PendingDownload {
return PendingDownload(
outputPath = bundle.getString("outputPath")!!,
mediaDisplayType = bundle.getString("mediaDisplayType"),
mediaDisplaySource = bundle.getString("mediaDisplaySource"),
iconUrl = bundle.getString("iconUrl"),
uniqueHash = bundle.getString("uniqueHash")
)
}
}
var changeListener = { _: DownloadStage, _: DownloadStage -> }
private var _stage: DownloadStage = DownloadStage.PENDING
var downloadStage: DownloadStage

View File

@@ -0,0 +1,5 @@
package me.rhunk.snapenhance.download.data
enum class SplitMediaAssetType {
ORIGINAL, OVERLAY
}

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.features
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader

View File

@@ -28,4 +28,8 @@ abstract class Feature(
* called on a dedicated thread when the Snapchat Activity is created
*/
open fun asyncOnActivityCreate() {}
protected fun findClass(name: String): Class<*> {
return context.androidContext.classLoader.loadClass(name)
}
}

View File

@@ -14,6 +14,7 @@ import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
@@ -61,7 +62,7 @@ class AutoUpdater : Feature("AutoUpdater", loadParams = FeatureLoadParams.ACTIVI
val downloadEndpoint = latestRelease.getJSONArray("assets").getJSONObject(0).getString("browser_download_url")
context.runOnUiThread {
AlertDialog.Builder(context.mainActivity)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["auto_updater.dialog_title"])
.setMessage(
context.translation.format("auto_updater.dialog_message",

View File

@@ -1,5 +1,6 @@
package me.rhunk.snapenhance.features.impl
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.Feature
@@ -13,9 +14,9 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
lateinit var conversationManager: Any
var openedConversationUUID: SnapUUID? = null
var lastOpenedConversationUUID: SnapUUID? = null
var lastFetchConversationUserUUID: SnapUUID? = null
var lastFetchConversationUUID: SnapUUID? = null
var lastFetchGroupConversationUUID: SnapUUID? = null
var lastFocusedMessageId: Long = -1
override fun init() {
@@ -25,6 +26,17 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
}
override fun onActivityCreate() {
context.mappings.getMappedObjectNullable("FriendsFeedEventDispatcher").let { it as? Map<*, *> }?.let { mappings ->
findClass(mappings["class"].toString()).hook("onItemLongPress", HookStage.BEFORE) { param ->
val viewItemContainer = param.arg<Any>(0)
val viewItem = viewItemContainer.getObjectField(mappings["viewModelField"].toString()).toString()
val conversationId = viewItem.substringAfter("conversationId: ").substring(0, 36).also {
if (it.startsWith("null")) return@hook
}
lastFetchGroupConversationUUID = SnapUUID.fromString(conversationId)
}
}
context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param ->
val userIdToConversation = (param.arg<ArrayList<*>>(0))
.takeIf { it.isNotEmpty() }

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.features.impl.downloader
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.features.BridgeFileFeature
import me.rhunk.snapenhance.features.FeatureLoadParams

View File

@@ -1,6 +1,5 @@
package me.rhunk.snapenhance.features.impl.downloader
import android.app.AlertDialog
import android.content.DialogInterface
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -9,10 +8,13 @@ import android.widget.ImageView
import com.arthenica.ffmpegkit.FFmpegKit
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo
import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistItem
import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem
@@ -20,6 +22,9 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.download.DownloadManagerClient
import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.download.data.toKeyPair
import me.rhunk.snapenhance.download.enums.DownloadMediaType
import me.rhunk.snapenhance.features.Feature
@@ -29,6 +34,7 @@ import me.rhunk.snapenhance.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.ui.download.MediaFilter
import me.rhunk.snapenhance.util.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.getObjectField
@@ -36,20 +42,17 @@ import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.util.snap.MediaType
import me.rhunk.snapenhance.util.snap.PreviewUtils
import java.io.File
import java.nio.file.Paths
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.path.inputStream
@OptIn(ExperimentalEncodingApi::class)
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null
private val isFFmpegPresent by lazy {
runCatching { FFmpegKit.execute("-version") }.isSuccess
@@ -70,22 +73,47 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
BitmojiSelfie.getBitmojiSelfie(it.bitmojiSelfieId!!, it.bitmojiAvatarId!!, BitmojiSelfie.BitmojiSelfieType.THREE_D)
}
val outputPath = File(
context.config.string(ConfigProperty.SAVE_FOLDER),
createNewFilePath(generatedHash, mediaDisplayType, pathSuffix)
).absolutePath
val downloadLogging = context.config.options(ConfigProperty.DOWNLOAD_LOGGING)
if (downloadLogging["started"] == true) {
context.shortToast(context.translation["download_processor.download_started_toast"])
}
val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix)
return DownloadManagerClient(
context = context,
mediaDisplaySource = mediaDisplaySource,
mediaDisplayType = mediaDisplayType,
iconUrl = iconUrl,
uniqueHash =
// If duplicate is allowed, we don't need to pass the hash
if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["allow_duplicate"] == false) {
generatedHash
} else null,
outputPath = outputPath
metadata = DownloadMetadata(
mediaIdentifier = if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["allow_duplicate"] == false) {
generatedHash
} else null,
mediaDisplaySource = mediaDisplaySource,
mediaDisplayType = mediaDisplayType,
iconUrl = iconUrl,
outputPath = outputPath
),
callback = object: DownloadCallback.Stub() {
override fun onSuccess(outputFile: String) {
if (downloadLogging["success"] != true) return
Logger.debug("onSuccess: outputFile=$outputFile")
context.shortToast(context.translation.format("download_processor.saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/")))
}
override fun onProgress(message: String) {
if (downloadLogging["progress"] != true) return
Logger.debug("onProgress: message=$message")
context.shortToast(message)
}
override fun onFailure(message: String, throwable: String?) {
if (downloadLogging["failure"] != true) return
Logger.debug("onFailure: message=$message, throwable=$throwable")
throwable?.let {
context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty()))
return
}
context.shortToast(message)
}
}
)
}
@@ -162,28 +190,31 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
}
private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<MediaType, MediaInfo>) {
private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
if (mediaInfoMap.isEmpty()) return
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
val overlay = mediaInfoMap[MediaType.OVERLAY]
val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
val overlayReference = overlay?.let { handleLocalReferences(it.uri) }
overlay?.let {
mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay ->
val overlayReference = handleLocalReferences(overlay.uri)
downloadManagerClient.downloadMediaWithOverlay(
originalMediaInfoReference,
overlayReference!!,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
videoEncryption = originalMediaInfo.encryption?.toKeyPair(),
overlayEncryption = overlay.encryption?.toKeyPair()
original = InputMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
originalMediaInfo.encryption?.toKeyPair()
),
overlay = InputMedia(
overlayReference,
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
overlay.encryption?.toKeyPair()
)
)
return
}
downloadManagerClient.downloadMedia(
downloadManagerClient.downloadSingleMedia(
originalMediaInfoReference,
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
originalMediaInfo.encryption?.toKeyPair()
@@ -199,7 +230,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
*/
private fun handleOperaMedia(
paramMap: ParamMap,
mediaInfoMap: Map<MediaType, MediaInfo>,
mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>,
forceDownload: Boolean
) {
//messages
@@ -229,14 +260,32 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
//private stories
paramMap["PLAYLIST_V2_GROUP"]?.toString()?.takeIf {
it.contains("storyUserId=") && (forceDownload || canAutoDownload("friend_stories"))
paramMap["PLAYLIST_V2_GROUP"]?.takeIf {
forceDownload || canAutoDownload("friend_stories")
}?.let { playlistGroup ->
val storyUserId = (playlistGroup.indexOf("storyUserId=") + 12).let {
playlistGroup.substring(it, playlistGroup.indexOf(",", it))
val playlistGroupString = playlistGroup.toString()
val storyUserId = if (playlistGroupString.contains("storyUserId=")) {
(playlistGroupString.indexOf("storyUserId=") + 12).let {
playlistGroupString.substring(it, playlistGroupString.indexOf(",", it))
}
} else {
//story replies
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }
?.invoke(playlistGroup)?.toString()
?.split(":")?.getOrNull(2) ?: return@let
val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
val conversationParticipants = context.database.getConversationParticipants(conversationMessage.client_conversation_id.toString()) ?: return@let
conversationParticipants.firstOrNull { it != conversationMessage.sender_id }
}
val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) ?: return
val author = context.database.getFriendInfo(
if (storyUserId == null || storyUserId == "null")
context.database.getMyUserId()!!
else storyUserId
) ?: throw Exception("Friend not found in database")
val authorName = author.usernameForSorting!!
downloadOperaMedia(provideClientDownloadManager(
@@ -333,7 +382,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
override fun asyncOnActivityCreate() {
val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class")
val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "class")
val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param ->
@@ -347,13 +396,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list"))
return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>()
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")
mediaInfoMap[MediaType.ORIGINAL] = MediaInfo(
mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
)
if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) {
mediaInfoMap[MediaType.OVERLAY] =
mediaInfoMap[SplitMediaAssetType.OVERLAY] =
MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
}
lastSeenMapParams = mediaParamMap
@@ -371,7 +420,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
}
arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName ->
arrayOf("onDisplayStateChange", "onDisplayStateChangeGesture").forEach { methodName ->
Hooker.hook(
operaViewerControllerClass,
context.mappings.getMappedValue("OperaPageViewController", methodName),
@@ -380,20 +429,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
}
/**
* Called when a message is focused in chat
*/
//TODO: use snapchat classes instead of database (when content is deleted)
fun onMessageActionMenu(isPreviewMode: Boolean) {
//check if the message was focused in a conversation
val messaging = context.feature(Messaging::class)
fun downloadMessageId(messageId: Long, isPreview: Boolean = false) {
val messageLogger = context.feature(MessageLogger::class)
if (messaging.openedConversationUUID == null) return
val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return
val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database")
//get the message author
val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!)!!
val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!) ?: throw Exception("Friend not found in database")
val authorName = friendInfo.usernameForSorting!!
var messageContent = message.message_content!!
@@ -425,10 +466,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
}
val translations = context.translation.getCategory("download_processor")
if (contentType != ContentType.NOTE &&
contentType != ContentType.SNAP &&
contentType != ContentType.EXTERNAL_MEDIA) {
context.shortToast("Unsupported content type $contentType")
context.shortToast(translations["unsupported_content_type_toast"])
return
}
@@ -440,7 +483,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
}
runCatching {
if (!isPreviewMode) {
if (!isPreview) {
val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage)
provideClientDownloadManager(
pathSuffix = authorName,
@@ -448,7 +491,7 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
mediaDisplaySource = authorName,
mediaDisplayType = MediaFilter.CHAT_MEDIA.mediaDisplayType,
friendInfo = friendInfo
).downloadMedia(
).downloadSingleMedia(
Base64.UrlSafe.encode(urlProto),
DownloadMediaType.PROTO_MEDIA,
encryption = encryptionKeys?.toKeyPair()
@@ -456,18 +499,23 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
return
}
if (EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) == null) {
context.shortToast(translations["failed_no_longer_available_toast"])
return
}
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) {
EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage)
}
runCatching {
val originalMedia = downloadedMediaList[MediaType.ORIGINAL] ?: return
val overlay = downloadedMediaList[MediaType.OVERLAY]
val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return
val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY]
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
if (bitmap == null) {
context.shortToast("Failed to create preview")
context.shortToast(translations["failed_to_create_preview_toast"])
return
}
@@ -475,21 +523,29 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
}
with(AlertDialog.Builder(context.mainActivity)) {
setTitle("Preview")
with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) {
setView(ImageView(context).apply {
setImageBitmap(bitmap)
})
setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() }
this@MediaDownloader.context.runOnUiThread { show() }
this@MediaDownloader.context.runOnUiThread { show()}
}
}.onFailure {
context.shortToast("Failed to create preview: $it")
context.shortToast(translations["failed_to_create_preview_toast"])
xposedLog(it)
}
}.onFailure {
context.longToast("Failed to download " + it.message)
context.longToast(translations["failed_generic_toast"])
xposedLog(it)
}
}
/**
* Called when a message is focused in chat
*/
fun onMessageActionMenu(isPreviewMode: Boolean) {
val messaging = context.feature(Messaging::class)
if (messaging.openedConversationUUID == null) return
downloadMessageId(messaging.lastFocusedMessageId, isPreviewMode)
}
}

View File

@@ -12,6 +12,7 @@ import android.widget.EditText
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
//TODO: fingerprint unlock
class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
@@ -32,7 +33,7 @@ class AppPasscode : Feature("App Passcode", loadParams = FeatureLoadParams.ACTIV
val mainActivity = context.mainActivity!!
setActivityVisibility(false)
val prompt = AlertDialog.Builder(mainActivity)
val prompt = ViewAppearanceHelper.newAlertDialogBuilder(mainActivity)
val createPrompt = {
val alertDialog = prompt.create()
val textView = EditText(mainActivity)

View File

@@ -0,0 +1,47 @@
package me.rhunk.snapenhance.features.impl.experiments
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class DeviceSpooferHook: Feature("device_spoofer", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
//FINGERPRINT
if(getFingerprint().isNotEmpty()) {
val fingerprintClass = android.os.Build::class.java
Hooker.hook(fingerprintClass, "FINGERPRINT", HookStage.BEFORE) { hookAdapter ->
hookAdapter.setResult(getFingerprint())
}
Hooker.hook(fingerprintClass, "deriveFingerprint", HookStage.BEFORE) { hookAdapter ->
hookAdapter.setResult(getFingerprint())
}
}
else {
Logger.xposedLog("Fingerprint is null, not spoofing")
}
//ANDROID ID
if(getAndroidId().isNotEmpty()) {
val settingsSecureClass = android.provider.Settings.Secure::class.java
Hooker.hook(settingsSecureClass, "getString", HookStage.BEFORE) { hookAdapter ->
if(hookAdapter.args()[1] == "android_id") {
hookAdapter.setResult(getAndroidId())
}
}
}
else {
Logger.xposedLog("Android ID is null, not spoofing")
}
}
private fun getFingerprint():String {
return context.config.string(ConfigProperty.FINGERPRINT)
}
private fun getAndroidId():String {
return context.config.string(ConfigProperty.ANDROID_ID)
}
}

View File

@@ -8,9 +8,11 @@ import me.rhunk.snapenhance.hook.Hooker
class MeoPasscodeBypass : Feature("Meo Passcode Bypass", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
val bcrypt = context.mappings.getMappedMap("BCrypt")
Hooker.hook(
context.mappings.getMappedClass("BCryptClass"),
context.mappings.getMappedValue("BCryptClassHashMethod"),
context.androidContext.classLoader.loadClass(bcrypt["class"].toString()),
bcrypt["hashMethod"].toString(),
HookStage.BEFORE,
{ context.config.bool(ConfigProperty.MEO_PASSCODE_BYPASS) },
) { param ->

View File

@@ -1,36 +1,39 @@
package me.rhunk.snapenhance.features.impl.privacy
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.NotificationType
import me.rhunk.snapenhance.data.wrapper.impl.MessageContent
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook
class PreventMessageSending : Feature("Send message override", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
class PreventMessageSending : Feature("Prevent message sending", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
Hooker.hook(
context.classCache.conversationManager,
"sendMessageWithContent",
HookStage.BEFORE
) { param ->
val message = MessageContent(param.arg(1))
val contentType = message.contentType
if (context.config.bool(ConfigProperty.PREVENT_STATUS_NOTIFICATIONS)) {
if (contentType == ContentType.STATUS_SAVE_TO_CAMERA_ROLL ||
contentType == ContentType.STATUS_CALL_MISSED_AUDIO ||
contentType == ContentType.STATUS_CALL_MISSED_VIDEO) {
param.setResult(null)
}
context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param ->
val messageUpdate = param.arg<Any>(2).toString();
val options by lazy {
context.config.options(ConfigProperty.PREVENT_SENDING_MESSAGES)
}
if (messageUpdate == "SCREENSHOT" && options["chat_screenshot"] == true) {
param.setResult(null)
}
if (context.config.bool(ConfigProperty.PREVENT_SCREENSHOT_NOTIFICATIONS)) {
if (contentType == ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT ||
contentType == ContentType.STATUS_CONVERSATION_CAPTURE_RECORD) {
param.setResult(null)
}
if (messageUpdate == "SCREEN_RECORD" && options["chat_screen_record"] == true) {
param.setResult(null)
}
}
context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param ->
val message = MessageContent(param.arg(1))
val contentType = message.contentType
val associatedType = NotificationType.fromContentType(contentType) ?: return@hook
val options = context.config.options(ConfigProperty.PREVENT_SENDING_MESSAGES)
if (options[associatedType.key] == true) {
Logger.debug("Preventing message sending for $associatedType")
param.setResult(null)
}
}
}

View File

@@ -1,5 +1,6 @@
package me.rhunk.snapenhance.features.impl.spying
import android.os.DeadObjectException
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import me.rhunk.snapenhance.Logger
@@ -11,6 +12,7 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import java.util.concurrent.Executors
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
@@ -23,6 +25,8 @@ class MessageLogger : Feature("MessageLogger",
const val PREFETCH_FEED_COUNT = 20
}
private val threadPool = Executors.newFixedThreadPool(10)
//two level of cache to avoid querying the database
private val fetchedMessages = mutableListOf<Long>()
private val deletedMessageCache = mutableMapOf<Long, JsonObject>()
@@ -59,7 +63,7 @@ class MessageLogger : Feature("MessageLogger",
measureTime {
context.database.getFriendFeed(PREFETCH_FEED_COUNT).forEach { friendFeedInfo ->
fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT))
fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList())
}
}.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") }
}
@@ -79,11 +83,13 @@ class MessageLogger : Feature("MessageLogger",
if (fetchedMessages.contains(messageId)) return
fetchedMessages.add(messageId)
context.executeAsync {
context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let {
return@executeAsync
}
context.bridgeClient.addMessageLoggerMessage(conversationId, messageId, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8))
threadPool.execute {
try {
context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let {
return@execute
}
context.bridgeClient.addMessageLoggerMessage(conversationId, messageId, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8))
} catch (ignored: DeadObjectException) {}
}
return

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.features.impl.spying
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.features.BridgeFileFeature
import me.rhunk.snapenhance.features.FeatureLoadParams

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.features.impl.tweaks
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.features.BridgeFileFeature
import me.rhunk.snapenhance.features.FeatureLoadParams

View File

@@ -9,6 +9,7 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.util.protobuf.ProtoReader
class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadParams = FeatureLoadParams.INIT_SYNC) {
@@ -24,7 +25,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara
if (messageProtoReader.readPath(3)?.getCount(3) != 1) {
context.runOnUiThread {
AlertDialog.Builder(context.mainActivity!!)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
.setMessage("You can only send one media at a time")
.setPositiveButton("OK", null)
.show()

View File

@@ -0,0 +1,24 @@
package me.rhunk.snapenhance.features.impl.tweaks
import android.app.AlertDialog
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook
import java.lang.reflect.Modifier
class GooglePlayServicesDialogs : Feature("Disable GMS Dialogs", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
if (!context.config.bool(ConfigProperty.DISABLE_GOOGLE_PLAY_DIALOGS)) return
findClass("com.google.android.gms.common.GoogleApiAvailability").methods
.first { Modifier.isStatic(it.modifiers) && it.returnType == AlertDialog::class.java }.let { method ->
method.hook(HookStage.BEFORE) { param ->
Logger.debug("GoogleApiAvailability.showErrorDialogFragment() called, returning null")
param.setResult(null)
}
}
}
}

View File

@@ -4,14 +4,15 @@ import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook
class MediaQualityLevelOverride : Feature("MediaQualityLevelOverride", loadParams = FeatureLoadParams.INIT_SYNC) {
override fun init() {
val enumQualityLevel = context.mappings.getMappedClass("enums", "QualityLevel")
val enumQualityLevel = context.mappings.getMappedClass("EnumQualityLevel")
val mediaQualityLevelProvider = context.mappings.getMappedMap("MediaQualityLevelProvider")
Hooker.hook(context.mappings.getMappedClass("MediaQualityLevelProvider"),
context.mappings.getMappedValue("MediaQualityLevelProviderMethod"),
context.androidContext.classLoader.loadClass(mediaQualityLevelProvider["class"].toString()).hook(
mediaQualityLevelProvider["method"].toString(),
HookStage.BEFORE,
{ context.config.bool(ConfigProperty.FORCE_MEDIA_SOURCE_QUALITY) }
) { param ->

View File

@@ -19,21 +19,24 @@ import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.MediaReferenceType
import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.util.snap.MediaType
import me.rhunk.snapenhance.util.snap.PreviewUtils
import me.rhunk.snapenhance.util.protobuf.ProtoReader
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
companion object{
const val ACTION_REPLY = "me.rhunk.snapenhance.action.REPLY"
const val ACTION_REPLY = "me.rhunk.snapenhance.action.notification.REPLY"
const val ACTION_DOWNLOAD = "me.rhunk.snapenhance.action.notification.DOWNLOAD"
}
private val notificationDataQueue = mutableMapOf<Long, NotificationData>() // messageId => notification
@@ -54,10 +57,6 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
)
}
private val cancelAsUserMethod by lazy {
XposedHelpers.findMethodExact(NotificationManager::class.java, "cancelAsUser", String::class.java, Int::class.javaPrimitiveType, UserHandle::class.java)
}
private val fetchConversationWithMessagesMethod by lazy {
context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"}
}
@@ -66,51 +65,69 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private fun setNotificationText(notification: Notification, text: String) {
private fun setNotificationText(notification: Notification, conversationId: String) {
val messageText = StringBuilder().apply {
cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach {
if (isNotEmpty()) append("\n")
append(it)
}
}.toString()
with(notification.extras) {
putString("android.text", text)
putString("android.bigText", text)
putString("android.text", messageText)
putString("android.bigText", messageText)
putParcelableArray("android.messages", messageText.split("\n").map {
Bundle().apply {
putBundle("extras", Bundle())
putString("text", it)
putLong("time", System.currentTimeMillis())
}
}.toTypedArray())
}
}
private fun computeNotificationText(conversationId: String): String {
val messageBuilder = StringBuilder()
cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.forEach {
if (messageBuilder.isNotEmpty()) messageBuilder.append("\n")
messageBuilder.append(it)
}
return messageBuilder.toString()
}
private fun setupNotificationActionButtons(contentType: ContentType, conversationId: String, messageId: Long, notificationData: NotificationData) {
val betterNotifications = context.config.options(ConfigProperty.BETTER_NOTIFICATIONS)
private fun setupNotificationActionButtons(conversationId: String, notificationData: NotificationData) {
val notificationBuilder = XposedHelpers.newInstance(
Notification.Builder::class.java,
context.androidContext,
notificationData.notification
) as Notification.Builder
val chatReplyInput = RemoteInput.Builder("chat_reply_input")
.setLabel("Reply")
.build()
val actions = mutableListOf<Notification.Action>()
actions.addAll(notificationData.notification.actions)
val replyIntent = Intent()
.setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name)
.putExtra("conversation_id", conversationId)
.putExtra("notification_id", notificationData.id)
.setAction(ACTION_REPLY)
val action = Notification.Action.Builder(
null,
"Reply",
PendingIntent.getBroadcast(
fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) {
if (!filter()) return
val intent = Intent().setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name)
.putExtra("conversation_id", conversationId)
.putExtra("notification_id", notificationData.id)
.putExtra("message_id", messageId)
.setAction(remoteAction)
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
context.androidContext,
System.nanoTime().toInt(),
replyIntent,
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
)
).addRemoteInput(chatReplyInput).build()
)).apply(builder).build()
actions.add(action)
}
notificationBuilder.setActions(action)
newAction("Reply", ACTION_REPLY, {
betterNotifications["reply_button"] == true && contentType == ContentType.CHAT
}) {
val chatReplyInput = RemoteInput.Builder("chat_reply_input")
.setLabel("Reply")
.build()
it.addRemoteInput(chatReplyInput)
}
newAction("Download", ACTION_DOWNLOAD, {
betterNotifications["download_button"] == true && (contentType == ContentType.EXTERNAL_MEDIA || contentType == ContentType.SNAP)
}) {}
notificationBuilder.setActions(*actions.toTypedArray())
notificationData.notification = notificationBuilder.build()
}
@@ -118,12 +135,14 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
Hooker.hook(broadcastReceiverClass, "onReceive", HookStage.BEFORE) { param ->
val androidContext = param.arg<Context>(0)
val intent = param.arg<Intent>(1)
if (intent.action != ACTION_REPLY) return@hook
param.setResult(null)
val conversationId = intent.getStringExtra("conversation_id") ?: return@hook
val messageId = intent.getLongExtra("message_id", -1)
val notificationId = intent.getIntExtra("notification_id", -1)
val notificationManager = androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { notificationId, notificationBuilder ->
notificationManager.activeNotifications.firstOrNull { it.id == notificationId }?.let {
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
notificationManager.activeNotifications.firstOrNull { it.id == id }?.let {
notificationBuilder(it.notification)
XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf(
it.tag, it.id, it.notification, it.user
@@ -131,22 +150,35 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
}
val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input")
.toString()
val conversationId = intent.getStringExtra("conversation_id")!!
val notificationId = intent.getIntExtra("notification_id", -1)
when (intent.action) {
ACTION_REPLY -> {
val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input")
.toString()
context.database.getMyUserId()?.let { context.database.getFriendInfo(it) }?.let { myUser ->
cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input")
context.database.getMyUserId()?.let { context.database.getFriendInfo(it) }?.let { myUser ->
cachedMessages.computeIfAbsent(conversationId) { mutableListOf() }.add("${myUser.displayName}: $input")
updateNotification(notificationId) { notification ->
setNotificationText(notification, computeNotificationText(conversationId))
updateNotification(notificationId) { notification ->
notification.flags = notification.flags or Notification.FLAG_ONLY_ALERT_ONCE
setNotificationText(notification, conversationId)
}
context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = {
context.longToast("Failed to send message: $it")
})
}
}
context.messageSender.sendChatMessage(listOf(SnapUUID.fromString(conversationId)), input, onError = {
context.longToast("Failed to send message: $it")
})
ACTION_DOWNLOAD -> {
runCatching {
context.feature(MediaDownloader::class).downloadMessageId(messageId, isPreview = false)
}.onFailure {
context.longToast(it)
}
}
else -> return@hook
}
param.setResult(null)
}
}
@@ -160,90 +192,80 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
))
}
notificationDataQueue.entries.onEach { (messageId, notificationData) ->
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return
val senderUsername by lazy {
context.database.getFriendInfo(snapMessage.senderId.toString())?.let {
it.displayName ?: it.username
}
}
val contentType = snapMessage.messageContent.contentType
val contentData = snapMessage.messageContent.content
val formatUsername: (String) -> String = { "$senderUsername: $it" }
val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } }
val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, computeNotificationText(conversationId))}
when (contentType) {
ContentType.NOTE -> {
notificationCache.add(formatUsername("sent audio note"))
appendNotifications()
}
ContentType.CHAT -> {
ProtoReader(contentData).getString(2, 1)?.trim()?.let {
notificationCache.add(formatUsername(it))
synchronized(notificationDataQueue) {
notificationDataQueue.entries.onEach { (messageId, notificationData) ->
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return
val senderUsername by lazy {
context.database.getFriendInfo(snapMessage.senderId.toString())?.let {
it.displayName ?: it.username
}
appendNotifications()
}
ContentType.SNAP, ContentType.EXTERNAL_MEDIA-> {
//serialize the message content into a json object
val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject
val mediaReferences = serializedMessageContent["mRemoteMediaReferences"]
.asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
.flatten()
mediaReferences.forEach { media ->
val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
runCatching {
val messageReader = ProtoReader(contentData)
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false)
val contentType = snapMessage.messageContent.contentType
val contentData = snapMessage.messageContent.content
val formatUsername: (String) -> String = { "$senderUsername: $it" }
val notificationCache = cachedMessages.let { it.computeIfAbsent(conversationId) { mutableListOf() } }
val appendNotifications: () -> Unit = { setNotificationText(notificationData.notification, 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-> {
//serialize the message content into a json object
val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject
val mediaReferences = serializedMessageContent["mRemoteMediaReferences"]
.asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
.flatten()
mediaReferences.forEach { media ->
val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
runCatching {
val messageReader = ProtoReader(contentData)
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false)
}
var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
downloadedMediaList[SplitMediaAssetType.OVERLAY]?.let {
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
}
val notificationBuilder = XposedHelpers.newInstance(
Notification.Builder::class.java,
context.androidContext,
notificationData.notification
) as Notification.Builder
notificationBuilder.setLargeIcon(bitmapPreview)
notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?)
sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true)
return@onEach
}.onFailure {
Logger.xposedLog("Failed to send preview notification", it)
}
var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[MediaType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
downloadedMediaList[MediaType.OVERLAY]?.let {
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
}
val notificationBuilder = XposedHelpers.newInstance(
Notification.Builder::class.java,
context.androidContext,
notificationData.notification
) as Notification.Builder
notificationBuilder.setLargeIcon(bitmapPreview)
notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?)
sendNotificationData(notificationData.copy(notification = notificationBuilder.build()), true)
return@onEach
}.onFailure {
Logger.xposedLog("Failed to send preview notification", it)
}
}
else -> {
notificationCache.add(formatUsername("sent $contentType"))
}
}
else -> {
notificationCache.add(formatUsername("sent $contentType"))
}
}
if (contentType == ContentType.CHAT && context.config.options(ConfigProperty.BETTER_NOTIFICATIONS)["reply_button"] == true) {
setupNotificationActionButtons(conversationId, notificationData)
}
sendNotificationData(notificationData, false)
}.clear()
}
private fun shouldIgnoreNotification(type: String): Boolean {
val states = context.config.options(ConfigProperty.NOTIFICATION_BLACKLIST)
states["snap"]?.let { if (type.endsWith("SNAP") && it) return true }
states["chat"]?.let { if (type.endsWith("CHAT") && it) return true }
states["typing"]?.let { if (type.endsWith("TYPING") && it) return true }
return false
sendNotificationData(notificationData, false)
}.clear()
}
}
override fun init() {
@@ -260,16 +282,16 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
val notificationType = extras.getString("notification_type") ?: return@hook
val conversationId = extras.getString("conversation_id") ?: return@hook
if (shouldIgnoreNotification(notificationType)) {
param.setResult(null)
return@hook
}
if (context.config.options(ConfigProperty.BETTER_NOTIFICATIONS)
.filter { it.value }.none { notificationType.endsWith(it.key.uppercase())}) return@hook
.filter { it.value }.map { it.key.uppercase() }.none {
notificationType.contains(it)
}) return@hook
val conversationManager: Any = context.feature(Messaging::class).conversationManager
notificationDataQueue[messageId.toLong()] = notificationData
synchronized(notificationDataQueue) {
notificationDataQueue[messageId.toLong()] = notificationData
}
val callback = CallbackBuilder(fetchConversationWithMessagesCallback)
.override("onFetchConversationWithMessagesComplete") { callbackParam ->
@@ -284,12 +306,32 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
param.setResult(null)
}
Hooker.hook(cancelAsUserMethod, HookStage.BEFORE) { param ->
XposedHelpers.findMethodExact(
NotificationManager::class.java,
"cancelAsUser", String::class.java,
Int::class.javaPrimitiveType,
UserHandle::class.java
).hook(HookStage.BEFORE) { param ->
val notificationId = param.arg<Int>(1)
notificationIdMap[notificationId]?.let {
cachedMessages[it]?.clear()
}
}
findClass("com.google.firebase.messaging.FirebaseMessagingService").run {
methods.first { it.declaringClass == this && it.returnType == Void::class.javaPrimitiveType && it.parameterCount == 1 && it.parameterTypes[0] == Intent::class.java }
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
val messageType = intent.getStringExtra("type") ?: return@hook
val states = context.config.options(ConfigProperty.NOTIFICATION_BLACKLIST)
Logger.xposedLog("received message type: $messageType")
if (states[messageType.replaceFirst("mischief_", "")] == true) {
param.setResult(null)
}
}
}
}
data class NotificationData(

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.features.impl.ui
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.BridgeFileFeature
import me.rhunk.snapenhance.features.FeatureLoadParams

View File

@@ -1,7 +1,9 @@
package me.rhunk.snapenhance.features.impl.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.text.SpannableString
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -32,7 +34,7 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE
param.setResult(null)
}
@SuppressLint("DiscouragedApi")
@SuppressLint("DiscouragedApi", "InternalInsetResource")
override fun onActivityCreate() {
val blockAds = context.config.bool(ConfigProperty.BLOCK_ADS)
val hiddenElements = context.config.options(ConfigProperty.HIDE_UI_ELEMENTS)
@@ -92,6 +94,20 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE
hideStorySection(param)
}
//mappings?
if (hideStorySection["hide_friend_suggestions"] == true && view.javaClass.superclass?.name?.endsWith("StackDrawLayout") == true) {
val layoutParams = view.layoutParams as? FrameLayout.LayoutParams ?: return@hook
if (layoutParams.width == -1 &&
layoutParams.height == -2 &&
view.javaClass.let { clazz ->
clazz.methods.any { it.returnType == SpannableString::class.java} &&
clazz.constructors.any { it.parameterCount == 1 && it.parameterTypes[0] == Context::class.java }
}
) {
hideStorySection(param)
}
}
if (hideStorySection["hide_following"] == true && (viewId == getIdentifier("df_small_story", "id"))
) {
hideStorySection(param)
@@ -101,9 +117,20 @@ class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CRE
hideStorySection(param)
}
if (isImmersiveCamera && view.id == getIdentifier("full_screen_surface_view", "id")) {
Hooker.hookObjectMethod(View::class.java, view, "setLayoutParams", HookStage.BEFORE) {
it.setArg(0, FrameLayout.LayoutParams(displayMetrics.widthPixels, displayMetrics.heightPixels))
if (isImmersiveCamera) {
if (view.id == getIdentifier("edits_container", "id")) {
val deviceAspectRatio = displayMetrics.widthPixels.toFloat() / displayMetrics.heightPixels.toFloat()
Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) {
val width = it.arg(2) as Int
val realHeight = (width / deviceAspectRatio).toInt()
it.setArg(3, realHeight)
}
}
if (view.id == getIdentifier("full_screen_surface_view", "id")) {
Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) {
it.setArg(1, 1)
it.setArg(3, displayMetrics.heightPixels)
}
}
}

View File

@@ -5,82 +5,78 @@ import de.robv.android.xposed.XposedBridge
import java.lang.reflect.Member
object Hooker {
private fun newMethodHook(
inline fun newMethodHook(
stage: HookStage,
consumer: (HookAdapter) -> Unit,
filter: ((HookAdapter) -> Boolean) = { true }
crossinline consumer: (HookAdapter) -> Unit,
crossinline filter: ((HookAdapter) -> Boolean) = { true }
): XC_MethodHook {
val callEvent = { param: XC_MethodHook.MethodHookParam<*> ->
HookAdapter(param).takeIf(filter)?.also(consumer)
}
return if (stage == HookStage.BEFORE) object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam<*>) {
callEvent(param)
HookAdapter(param).takeIf(filter)?.also(consumer)
}
} else object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam<*>) {
callEvent(param)
HookAdapter(param).takeIf(filter)?.also(consumer)
}
}
}
fun hook(
inline fun hook(
clazz: Class<*>,
methodName: String,
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = hook(clazz, methodName, stage, { true }, consumer)
fun hook(
inline fun hook(
clazz: Class<*>,
methodName: String,
stage: HookStage,
filter: (HookAdapter) -> Boolean,
consumer: (HookAdapter) -> Unit
crossinline filter: (HookAdapter) -> Boolean,
crossinline consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter))
fun hook(
inline fun hook(
member: Member,
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook {
return hook(member, stage, { true }, consumer)
}
fun hook(
inline fun hook(
member: Member,
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
crossinline filter: ((HookAdapter) -> Boolean),
crossinline consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook {
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter))
}
fun hookConstructor(
inline fun hookConstructor(
clazz: Class<*>,
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
) {
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer))
}
fun hookConstructor(
inline fun hookConstructor(
clazz: Class<*>,
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
crossinline filter: ((HookAdapter) -> Boolean),
crossinline consumer: (HookAdapter) -> Unit
) {
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter))
}
fun hookObjectMethod(
inline fun hookObjectMethod(
clazz: Class<*>,
instance: Any,
methodName: String,
stage: HookStage,
hookConsumer: (HookAdapter) -> Unit
crossinline hookConsumer: (HookAdapter) -> Unit
) {
val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet()
hook(clazz, methodName, stage) { param->
@@ -92,11 +88,11 @@ object Hooker {
}.also { unhooks.addAll(it) }
}
fun ephemeralHook(
inline fun ephemeralHook(
clazz: Class<*>,
methodName: String,
stage: HookStage,
hookConsumer: (HookAdapter) -> Unit
crossinline hookConsumer: (HookAdapter) -> Unit
) {
val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet()
hook(clazz, methodName, stage) { param->
@@ -105,12 +101,12 @@ object Hooker {
}.also { unhooks.addAll(it) }
}
fun ephemeralHookObjectMethod(
inline fun ephemeralHookObjectMethod(
clazz: Class<*>,
instance: Any,
methodName: String,
stage: HookStage,
hookConsumer: (HookAdapter) -> Unit
crossinline hookConsumer: (HookAdapter) -> Unit
) {
val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet()
hook(clazz, methodName, stage) { param->
@@ -121,37 +117,37 @@ object Hooker {
}
}
fun Class<*>.hookConstructor(
inline fun Class<*>.hookConstructor(
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
) = Hooker.hookConstructor(this, stage, consumer)
fun Class<*>.hookConstructor(
inline fun Class<*>.hookConstructor(
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
crossinline filter: ((HookAdapter) -> Boolean),
crossinline consumer: (HookAdapter) -> Unit
) = Hooker.hookConstructor(this, stage, filter, consumer)
fun Class<*>.hook(
inline fun Class<*>.hook(
methodName: String,
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, consumer)
fun Class<*>.hook(
inline fun Class<*>.hook(
methodName: String,
stage: HookStage,
filter: (HookAdapter) -> Boolean,
consumer: (HookAdapter) -> Unit
crossinline filter: (HookAdapter) -> Boolean,
crossinline consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = Hooker.hook(this, methodName, stage, filter, consumer)
fun Member.hook(
inline fun Member.hook(
stage: HookStage,
consumer: (HookAdapter) -> Unit
crossinline consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook = Hooker.hook(this, stage, consumer)
fun Member.hook(
inline fun Member.hook(
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
crossinline filter: ((HookAdapter) -> Boolean),
crossinline consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer)

View File

@@ -3,7 +3,7 @@ package me.rhunk.snapenhance.manager.impl
import com.google.gson.JsonObject
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.config.ConfigAccessor
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.manager.Manager

View File

@@ -11,35 +11,37 @@ import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.experiments.AmoledDarkMode
import me.rhunk.snapenhance.features.impl.experiments.AppPasscode
import me.rhunk.snapenhance.features.impl.experiments.DeviceSpooferHook
import me.rhunk.snapenhance.features.impl.experiments.InfiniteStoryBoost
import me.rhunk.snapenhance.features.impl.experiments.MeoPasscodeBypass
import me.rhunk.snapenhance.features.impl.experiments.UnlimitedMultiSnap
import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave
import me.rhunk.snapenhance.features.impl.tweaks.AutoSave
import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction
import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride
import me.rhunk.snapenhance.features.impl.tweaks.LocationSpoofer
import me.rhunk.snapenhance.features.impl.tweaks.MediaQualityLevelOverride
import me.rhunk.snapenhance.features.impl.tweaks.Notifications
import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus
import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime
import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics
import me.rhunk.snapenhance.features.impl.privacy.PreventMessageSending
import me.rhunk.snapenhance.features.impl.spying.AnonymousStoryViewing
import me.rhunk.snapenhance.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.features.impl.spying.PreventReadReceipts
import me.rhunk.snapenhance.features.impl.spying.StealthMode
import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave
import me.rhunk.snapenhance.features.impl.tweaks.AutoSave
import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks
import me.rhunk.snapenhance.features.impl.tweaks.DisableVideoLengthRestriction
import me.rhunk.snapenhance.features.impl.tweaks.GalleryMediaSendOverride
import me.rhunk.snapenhance.features.impl.tweaks.GooglePlayServicesDialogs
import me.rhunk.snapenhance.features.impl.tweaks.LocationSpoofer
import me.rhunk.snapenhance.features.impl.tweaks.MediaQualityLevelOverride
import me.rhunk.snapenhance.features.impl.tweaks.Notifications
import me.rhunk.snapenhance.features.impl.tweaks.SnapchatPlus
import me.rhunk.snapenhance.features.impl.tweaks.UnlimitedSnapViewTime
import me.rhunk.snapenhance.features.impl.ui.PinConversations
import me.rhunk.snapenhance.features.impl.ui.StartupPageOverride
import me.rhunk.snapenhance.features.impl.ui.UITweaks
import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector
import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector
import java.util.concurrent.Executors
import kotlin.reflect.KClass
class FeatureManager(private val context: ModContext) : Manager {
private val asyncLoadExecutorService = Executors.newCachedThreadPool()
private val asyncLoadExecutorService = Executors.newFixedThreadPool(5)
private val features = mutableListOf<Feature>()
private fun register(featureClass: KClass<out Feature>) {
@@ -88,7 +90,9 @@ class FeatureManager(private val context: ModContext) : Manager {
register(AmoledDarkMode::class)
register(PinConversations::class)
register(UnlimitedMultiSnap::class)
register(DeviceSpooferHook::class)
register(StartupPageOverride::class)
register(GooglePlayServicesDialogs::class)
initializeFeatures()
}

View File

@@ -2,45 +2,44 @@ package me.rhunk.snapenhance.manager.impl
import android.app.AlertDialog
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.mapping.Mapper
import me.rhunk.snapenhance.mapping.impl.BCryptClassMapper
import me.rhunk.snapenhance.mapping.impl.CallbackMapper
import me.rhunk.snapenhance.mapping.impl.DefaultMediaItemMapper
import me.rhunk.snapenhance.mapping.impl.EnumMapper
import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper
import me.rhunk.snapenhance.mapping.impl.PlatformAnalyticsCreatorMapper
import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper
import me.rhunk.snapenhance.mapping.impl.ScCameraSettingsMapper
import me.rhunk.snapenhance.mapping.impl.StoryBoostStateMapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapmapper.Mapper
import me.rhunk.snapmapper.impl.BCryptClassMapper
import me.rhunk.snapmapper.impl.CallbackMapper
import me.rhunk.snapmapper.impl.DefaultMediaItemMapper
import me.rhunk.snapmapper.impl.EnumMapper
import me.rhunk.snapmapper.impl.FriendsFeedEventDispatcherMapper
import me.rhunk.snapmapper.impl.MediaQualityLevelProviderMapper
import me.rhunk.snapmapper.impl.OperaPageViewControllerMapper
import me.rhunk.snapmapper.impl.PlatformAnalyticsCreatorMapper
import me.rhunk.snapmapper.impl.PlusSubscriptionMapper
import me.rhunk.snapmapper.impl.ScCameraSettingsMapper
import me.rhunk.snapmapper.impl.StoryBoostStateMapper
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import kotlin.system.measureTimeMillis
@Suppress("UNCHECKED_CAST")
class MappingManager(private val context: ModContext) : Manager {
private val mappers = mutableListOf<Mapper>().apply {
add(CallbackMapper())
add(EnumMapper())
add(OperaPageViewControllerMapper())
add(PlusSubscriptionMapper())
add(DefaultMediaItemMapper())
add(BCryptClassMapper())
add(PlatformAnalyticsCreatorMapper())
add(ScCameraSettingsMapper())
add(StoryBoostStateMapper())
}
private val mappers = arrayOf(
BCryptClassMapper::class,
CallbackMapper::class,
DefaultMediaItemMapper::class,
MediaQualityLevelProviderMapper::class,
EnumMapper::class,
OperaPageViewControllerMapper::class,
PlatformAnalyticsCreatorMapper::class,
PlusSubscriptionMapper::class,
ScCameraSettingsMapper::class,
StoryBoostStateMapper::class,
FriendsFeedEventDispatcherMapper::class
)
private val mappings = ConcurrentHashMap<String, Any>()
val areMappingsLoaded: Boolean
@@ -69,7 +68,7 @@ class MappingManager(private val context: ModContext) : Manager {
return
}
context.runOnUiThread {
val statusDialogBuilder = AlertDialog.Builder(context.mainActivity, AlertDialog.THEME_DEVICE_DEFAULT_DARK)
val statusDialogBuilder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setMessage("Generating mappings, please wait...")
.setCancelable(false)
.setView(android.widget.ProgressBar(context.mainActivity).apply {
@@ -127,70 +126,27 @@ class MappingManager(private val context: ModContext) : Manager {
}
}
private fun executeMappers(classes: List<Class<*>>) = runBlocking {
val jobs = mutableListOf<Job>()
mappers.forEach { mapper ->
mapper.context = context
launch {
runCatching {
mapper.useClasses(context.androidContext.classLoader, classes, mappings)
}.onFailure {
Logger.xposedLog("Failed to execute mapper ${mapper.javaClass.simpleName}", it)
}
}.also { jobs.add(it) }
}
jobs.joinAll()
}
@Suppress("UNCHECKED_CAST", "DEPRECATION")
@Suppress("DEPRECATION")
private fun refresh() {
val classes: MutableList<Class<*>> = ArrayList()
val mapper = Mapper(*mappers)
val classLoader = context.androidContext.classLoader
val dexPathList = classLoader.getObjectField("pathList")!!
val dexElements = dexPathList.getObjectField("dexElements") as Array<Any>
dexElements.forEach { dexElement: Any ->
(dexElement.getObjectField("dexFile") as dalvik.system.DexFile?)?.apply {
entries().toList().forEach fileList@{ className ->
//ignore classes without a dot in them
if (className.contains(".") && !className.startsWith("com.snap")) return@fileList
runCatching {
classLoader.loadClass(className)?.let {
//force load fields to avoid ClassNotFoundExceptions when executing mappers
it.declaredFields
classes.add(it)
}
}.onFailure {
Logger.debug("Failed to load class $className")
}
}
}
runCatching {
mapper.loadApk(context.androidContext.packageManager.getApplicationInfo(
Constants.SNAPCHAT_PACKAGE_NAME,
0
).sourceDir)
}.onFailure {
throw Exception("Failed to load APK", it)
}
executeMappers(classes)
write()
}
private fun write() {
val mappingsObject = JsonObject()
mappingsObject.addProperty("snap_build_number", snapBuildNumber)
mappings.forEach { (key, value) ->
if (value is List<*>) {
mappingsObject.add(key, context.gson.toJsonTree(value))
return@forEach
measureTimeMillis {
val result = mapper.start().apply {
addProperty("snap_build_number", snapBuildNumber)
}
if (value is Map<*, *>) {
mappingsObject.add(key, context.gson.toJsonTree(value))
return@forEach
}
mappingsObject.addProperty(key, value.toString())
context.bridgeClient.writeFile(BridgeFileType.MAPPINGS, result.toString().toByteArray())
}.also {
Logger.xposedLog("Generated mappings in $it ms")
}
context.bridgeClient.writeFile(
BridgeFileType.MAPPINGS,
mappingsObject.toString().toByteArray()
)
}
fun getMappedObject(key: String): Any {
@@ -200,6 +156,10 @@ class MappingManager(private val context: ModContext) : Manager {
throw Exception("No mapping found for $key")
}
fun getMappedObjectNullable(key: String): Any? {
return mappings[key]
}
fun getMappedClass(className: String): Class<*> {
return context.androidContext.classLoader.loadClass(getMappedObject(className) as String)
}

View File

@@ -1,13 +0,0 @@
package me.rhunk.snapenhance.mapping
import me.rhunk.snapenhance.ModContext
abstract class Mapper {
lateinit var context: ModContext
abstract fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
)
}

View File

@@ -1,26 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Modifier
class BCryptClassMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
if (!Modifier.isFinal(clazz.modifiers)) continue
clazz.declaredFields.firstOrNull { it.type == IntArray::class.java && Modifier.isStatic(it.modifiers)}?.let { field ->
val fieldData = field.get(null)
if (fieldData !is IntArray) return@let
if (fieldData.size != 18 || fieldData[0] != 608135816) return@let
mappings["BCryptClass"] = clazz.name
mappings["BCryptClassHashMethod"] = clazz.methods.first {
it.parameterTypes.size == 2 && it.returnType == String::class.java && it.parameterTypes[0] == String::class.java && it.parameterTypes[1] == String::class.java
}.name
return
}
}
}
}

View File

@@ -1,29 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Method
import java.lang.reflect.Modifier
class CallbackMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
val callbackMappings = HashMap<String, String>()
classes.forEach { clazz ->
val superClass = clazz.superclass ?: return@forEach
if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach
if (!Modifier.isAbstract(superClass.modifiers)) return@forEach
if (superClass.declaredMethods.any { method: Method ->
method.name == "onError"
}) {
callbackMappings[superClass.simpleName] = clazz.name
}
}
debug("found " + callbackMappings.size + " callbacks")
mappings["callbacks"] = callbackMappings
}
}

View File

@@ -1,24 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import android.net.Uri
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Modifier
class DefaultMediaItemMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
if (clazz.superclass == null || !Modifier.isAbstract(clazz.superclass.modifiers)) continue
if (clazz.superclass.interfaces.isEmpty() || clazz.superclass.interfaces[0] != Comparable::class.java) continue
if (clazz.methods.none { it.returnType == Uri::class.java }) continue
val constructorParameters = clazz.constructors[0]?.parameterTypes ?: continue
if (constructorParameters.size < 6 || constructorParameters[5] != Long::class.javaPrimitiveType) continue
mappings["DefaultMediaItem"] = clazz.name
}
}
}

View File

@@ -1,66 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.Objects
class EnumMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
val enumMappings = HashMap<String, String>()
var enumQualityLevel: Class<*>? = null
//settings classes have an interface that extends Serializable and contains the getName method
//this enum classes are used to store the settings values
//Setting enum class -> implements an interface -> getName method
classes.forEach { clazz ->
if (!clazz.isEnum) return@forEach
//quality level enum
if (enumQualityLevel == null) {
if (clazz.enumConstants.any { it.toString().startsWith("LEVEL_NONE") }) {
enumMappings["QualityLevel"] = clazz.name
enumQualityLevel = clazz
}
}
if (clazz.interfaces.isEmpty()) return@forEach
val serializableInterfaceClass = clazz.interfaces[0]
if (serializableInterfaceClass.methods
.filter { method: Method -> method.declaringClass == serializableInterfaceClass }
.none { method: Method -> method.name == "getName" }
) return@forEach
runCatching {
val getEnumNameMethod =
serializableInterfaceClass.methods.first { it!!.returnType.isEnum }
clazz.enumConstants?.onEach { enumConstant ->
val enumName =
Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString()
enumMappings[enumName] = clazz.name
}
}
}
debug("found " + enumMappings.size + " enums")
mappings["enums"] = enumMappings
//find the media quality level provider
for (clazz in classes) {
if (!Modifier.isAbstract(clazz.modifiers)) continue
if (clazz.fields.none { Modifier.isTransient(it.modifiers) }) continue
clazz.methods.firstOrNull { it.returnType == enumQualityLevel }?.let {
mappings["MediaQualityLevelProvider"] = clazz.name
mappings["MediaQualityLevelProviderMethod"] = it.name
debug("found MediaQualityLevelProvider: ${clazz.name}.${it.name}")
return
}
}
}
}

View File

@@ -1,78 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
import me.rhunk.snapenhance.util.ReflectionHelper
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.Arrays
class OperaPageViewControllerMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
var operaPageViewControllerClass: Class<*>? = null
for (aClass in classes) {
if (!Modifier.isAbstract(aClass.modifiers)) continue
if (aClass.interfaces.isEmpty()) continue
val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field ->
val modifiers = field.modifiers
Modifier.isStatic(modifiers) && Modifier.isFinal(
modifiers
)
}.filter { field: Field ->
try {
return@filter "ad_product_type" == String.format("%s", field[null])
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
false
}.count()
if (foundFields == 0L) continue
operaPageViewControllerClass = aClass
break
}
if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found")
val members = HashMap<String, String>()
members["Class"] = operaPageViewControllerClass.name
operaPageViewControllerClass.fields.forEach { field ->
val fieldType = field.type
if (fieldType.isEnum) {
fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" }
.let { members["viewStateField"] = field.name }
}
if (fieldType == ArrayList::class.java) {
members["layerListField"] = field.name
}
}
val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field ->
field.name == members["viewStateField"]
}.type
//find the method that call the onDisplayStateChange method
members["onDisplayStateChange"] =
operaPageViewControllerClass.methods.first { method: Method ->
if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false
val firstParameterClass = method.parameterTypes[0]
//check if the class contains a field with the enumViewStateClass type
ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null
}.name
//find the method that call the onDisplayStateChange method from gestures
members["onDisplayStateChange2"] =
operaPageViewControllerClass.methods.first { method: Method ->
if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false
val firstParameterClass = method.parameterTypes[0]
val secondParameterClass = method.parameterTypes[1]
firstParameterClass.isEnum && secondParameterClass.isEnum
}.name
mappings["OperaPageViewController"] = members
return
}
}

View File

@@ -1,26 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
class PlatformAnalyticsCreatorMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
if (clazz.isEnum || clazz.isInterface) continue
val constructors = clazz.constructors
if (constructors.isEmpty()) continue
val firstConstructor = constructors[0]
// 47 is the number of parameters of the constructor
// can change in future versions
if (firstConstructor.parameterCount != 47) continue
if (!firstConstructor.parameterTypes[0].isEnum) continue
if (firstConstructor.parameterTypes[0].enumConstants.none { it.toString() == "IN_APP_NOTIFICATION" }) continue
mappings["PlatformAnalyticsCreator"] = clazz.name
return
}
}
}

View File

@@ -1,27 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Modifier
class PlusSubscriptionMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
clazz.declaredFields.firstOrNull {
it.type == clazz &&
Modifier.isFinal(it.modifiers) &&
Modifier.isStatic(it.modifiers) &&
runCatching {
it?.get(null).toString().startsWith("PlusSubscriptionState")
}.getOrDefault(false)
} ?: continue
mappings["SubscriptionInfoClass"] = clazz.constructors[0]!!.parameterTypes[0]!!.name
return
}
}
}

View File

@@ -1,21 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
class ScCameraSettingsMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
if (clazz.constructors.isEmpty()) continue
val parameters = clazz.constructors.first().parameterTypes
if (parameters.size < 27) continue
val firstParameter = parameters[0]
if (!firstParameter.isEnum || firstParameter.enumConstants.find { it.toString() == "CONTINUOUS_PICTURE" } == null) continue
mappings["ScCameraSettings"] = clazz.name
return
}
}
}

View File

@@ -1,21 +0,0 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
class StoryBoostStateMapper : Mapper(){
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
for (clazz in classes) {
val firstConstructor = clazz.constructors.firstOrNull() ?: continue
if (firstConstructor.parameterCount != 3) continue
if (firstConstructor.parameterTypes[1] != Long::class.javaPrimitiveType || firstConstructor.parameterTypes[2] != Long::class.javaPrimitiveType) continue
val storyBoostEnumClass = firstConstructor.parameterTypes[0]
if (!storyBoostEnumClass.isEnum || storyBoostEnumClass.enumConstants.none { it.toString() == "NeedSubscriptionCannotSubscribe" }) continue
mappings["StoryBoostStateClass"] = clazz.name
return
}
}
}

View File

@@ -0,0 +1,85 @@
package me.rhunk.snapenhance.ui
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.util.ActivityResultCallback
import kotlin.math.abs
import kotlin.random.Random
class ItemHelper(
private val context : Context
) {
val positiveButtonText by lazy {
SharedContext.translation["button.ok"]
}
val cancelButtonText by lazy {
SharedContext.translation["button.cancel"]
}
fun longToast(message: String, context: Context) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
fun createTranslatedTextView(property: ConfigProperty, shouldTranslatePropertyValue: Boolean = true): TextView {
return object: TextView(context) {
override fun setText(text: CharSequence?, type: BufferType?) {
val newText = text?.takeIf { it.isNotEmpty() }?.let {
if (!shouldTranslatePropertyValue || property.disableValueLocalization) it
else SharedContext.translation[property.getOptionTranslationKey(it.toString())]
}?.let {
if (it.length > 20) {
"...${it.substring(it.length - 20)}"
} else {
it
}
} ?: ""
super.setTextColor(context.getColor(R.color.tertiaryText))
super.setText(newText, type)
}
}
}
fun askForValue(property: ConfigProperty, requestedInputType: Int, callback: (String) -> Unit) {
val editText = EditText(context).apply {
inputType = requestedInputType
setText(property.valueContainer.value().toString())
}
AlertDialog.Builder(context)
.setTitle(SharedContext.translation["property.${property.translationKey}.name"])
.setView(editText)
.setPositiveButton(positiveButtonText) { _, _ ->
callback(editText.text.toString())
}
.setNegativeButton(cancelButtonText) { dialog, _ ->
dialog.cancel()
}
.show()
}
fun askForFolder(activity: Activity, property: ConfigProperty, callback: (String) -> Unit): Pair<Int, ActivityResultCallback> {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val requestCode = abs(Random.nextInt())
activity.startActivityForResult(intent, requestCode)
return requestCode to let@{_, resultCode, data ->
if (resultCode != Activity.RESULT_OK) return@let
val uri = data?.data ?: return@let
val value = uri.toString()
activity.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
property.valueContainer.writeFrom(value)
callback(value)
}
}
}

View File

@@ -1,6 +1,8 @@
package me.rhunk.snapenhance.ui.menu
package me.rhunk.snapenhance.ui
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
@@ -90,4 +92,6 @@ object ViewAppearanceHelper {
)
}
}
fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert)
}

View File

@@ -2,13 +2,13 @@ package me.rhunk.snapenhance.ui.config
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Bundle
import android.text.Html
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.ImageButton
import android.widget.Switch
import android.widget.TextView
@@ -16,7 +16,6 @@ import android.widget.Toast
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.ConfigWrapper
import me.rhunk.snapenhance.config.ConfigCategory
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.config.impl.ConfigIntegerValue
@@ -24,9 +23,14 @@ import me.rhunk.snapenhance.config.impl.ConfigStateListValue
import me.rhunk.snapenhance.config.impl.ConfigStateSelection
import me.rhunk.snapenhance.config.impl.ConfigStateValue
import me.rhunk.snapenhance.config.impl.ConfigStringValue
import me.rhunk.snapenhance.ui.ItemHelper
import me.rhunk.snapenhance.util.ActivityResultCallback
import kotlin.system.exitProcess
class ConfigActivity : Activity() {
private val config = ConfigWrapper()
private val itemHelper = ItemHelper(this)
private val activityResultCallbacks = mutableMapOf<Int, ActivityResultCallback>()
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
@@ -37,65 +41,20 @@ class ConfigActivity : Activity() {
override fun onDestroy() {
super.onDestroy()
config.writeConfig()
SharedContext.config.writeConfig()
}
override fun onPause() {
super.onPause()
config.writeConfig()
SharedContext.config.writeConfig()
}
private val positiveButtonText by lazy {
SharedContext.translation["button.ok"]
}
private val cancelButtonText by lazy {
SharedContext.translation["button.cancel"]
}
private fun longToast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private fun createTranslatedTextView(property: ConfigProperty, shouldTranslatePropertyValue: Boolean = true): TextView {
return object: TextView(this) {
override fun setText(text: CharSequence?, type: BufferType?) {
val newText = text?.takeIf { it.isNotEmpty() }?.let {
if (!shouldTranslatePropertyValue || property.disableValueLocalization) it
else SharedContext.translation["option.property." + property.translationKey + "." + it]
}?.let {
if (it.length > 20) {
it.substring(0, 20) + "..."
} else {
it
}
} ?: ""
super.setTextColor(getColor(R.color.tertiaryText))
super.setText(newText, type)
}
}
}
private fun askForValue(property: ConfigProperty, requestedInputType: Int, callback: (String) -> Unit) {
val editText = EditText(this).apply {
inputType = requestedInputType
setText(property.valueContainer.value().toString())
}
AlertDialog.Builder(this)
.setTitle(SharedContext.translation["property.${property.translationKey}.name"])
.setView(editText)
.setPositiveButton(positiveButtonText) { _, _ ->
callback(editText.text.toString())
}
.setNegativeButton(cancelButtonText) { dialog, _ ->
dialog.cancel()
}
.show()
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
activityResultCallbacks[requestCode]?.invoke(requestCode, resultCode, data)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
config.loadFromContext(this)
SharedContext.ensureInitialized(this)
setContentView(R.layout.config_activity)
@@ -126,9 +85,37 @@ class ConfigActivity : Activity() {
})
}
//check if save folder is set
//TODO: first run activity
run {
val saveFolder = SharedContext.config.string(ConfigProperty.SAVE_FOLDER)
val itemHelper = ItemHelper(this)
if (saveFolder.isEmpty() || !saveFolder.startsWith("content://")) {
AlertDialog.Builder(this)
.setTitle("Save folder")
.setMessage("Please select a folder where you want to save downloaded files.")
.setPositiveButton("Select") { _, _ ->
val (requestCode, callback) = itemHelper.askForFolder(
this,
ConfigProperty.SAVE_FOLDER
) {}
activityResultCallbacks[requestCode] = { a1, a2, a3 ->
callback(a1, a2, a3)
Toast.makeText(this, "Save Folder set!", Toast.LENGTH_SHORT).show()
finish()
}
}
.setNegativeButton("Cancel") { _, _ ->
exitProcess(0)
}
.show()
}
}
var currentCategory: ConfigCategory? = null
config.entries().forEach { (property, value) ->
SharedContext.config.entries().filter { !it.key.category.hidden }.forEach { (property, value) ->
val configItem = layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false)
fun addSeparator() {
@@ -140,6 +127,7 @@ class ConfigActivity : Activity() {
}
if (property.category != currentCategory) {
if(!property.shouldAppearInSettings) return@forEach
currentCategory = property.category
with(layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false)) {
findViewById<TextView>(R.id.name).apply {
@@ -187,22 +175,32 @@ class ConfigActivity : Activity() {
addValueView(switch)
}
is ConfigStringValue, is ConfigIntegerValue -> {
val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = false).also {
val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false).also {
it.text = value.value().toString()
}
configItem.setOnClickListener {
if (value is ConfigStringValue && value.isFolderPath) {
val (requestCode, callback) = itemHelper.askForFolder(this, property) {
value.writeFrom(it)
textView.text = value.value()
}
activityResultCallbacks[requestCode] = callback
return@setOnClickListener
}
if (value is ConfigIntegerValue) {
askForValue(property, InputType.TYPE_CLASS_NUMBER) {
itemHelper.askForValue(property, InputType.TYPE_CLASS_NUMBER) {
try {
value.writeFrom(it)
textView.text = value.value().toString()
} catch (e: NumberFormatException) {
longToast(SharedContext.translation["config_activity.invalid_number_toast"])
itemHelper.longToast(SharedContext.translation["config_activity.invalid_number_toast"], this)
}
}
return@setOnClickListener
}
askForValue(property, InputType.TYPE_CLASS_TEXT) {
itemHelper.askForValue(property, InputType.TYPE_CLASS_TEXT) {
value.writeFrom(it)
textView.text = value.value().toString()
}
@@ -210,7 +208,7 @@ class ConfigActivity : Activity() {
addValueView(textView)
}
is ConfigStateListValue -> {
val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = false)
val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false)
val values = value.value()
fun updateText() {
@@ -222,13 +220,13 @@ class ConfigActivity : Activity() {
configItem.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(propertyName)
.setPositiveButton(positiveButtonText) { _, _ ->
.setPositiveButton(itemHelper.positiveButtonText) { _, _ ->
updateText()
}
.setMultiChoiceItems(
values.keys.map {
if (property.disableValueLocalization) it
else SharedContext.translation["option.property." + property.translationKey + "." + it]
else SharedContext.translation[property.getOptionTranslationKey(it)]
}.toTypedArray(),
values.map { it.value }.toBooleanArray()
) { _, which, isChecked ->
@@ -240,7 +238,7 @@ class ConfigActivity : Activity() {
addValueView(textView)
}
is ConfigStateSelection -> {
val textView = createTranslatedTextView(property, shouldTranslatePropertyValue = true)
val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = true)
textView.text = value.value()
configItem.setOnClickListener {
@@ -250,14 +248,14 @@ class ConfigActivity : Activity() {
builder.setSingleChoiceItems(
value.keys().toTypedArray().map {
if (property.disableValueLocalization) it
else SharedContext.translation["option.property." + property.translationKey + "." + it]
else SharedContext.translation[property.getOptionTranslationKey(it)]
}.toTypedArray(),
value.keys().indexOf(value.value())
) { _, which ->
value.writeFrom(value.keys()[which])
}
builder.setPositiveButton(positiveButtonText) { _, _ ->
builder.setPositiveButton(itemHelper.positiveButtonText) { _, _ ->
textView.text = value.value()
}

View File

@@ -11,8 +11,9 @@ import android.widget.TextView
import android.widget.Toast
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.common.impl.file.BridgeFileType
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.ui.config.ConfigActivity
import me.rhunk.snapenhance.ui.spoof.DeviceSpooferActivity
import java.io.File
class ActionListAdapter(
@@ -70,6 +71,9 @@ class DebugSettingsLayoutInflater(
add(SharedContext.translation["config_activity.title"] to {
activity.startActivity(Intent(activity, ConfigActivity::class.java))
})
add(SharedContext.translation["spoof_activity.title"] to {
activity.startActivity(Intent(activity, DeviceSpooferActivity::class.java))
})
add(debugSettingsTranslation["clear_cache_title"] to {
context.cacheDir.listFiles()?.forEach {
it.deleteRecursively()

View File

@@ -1,5 +1,6 @@
package me.rhunk.snapenhance.ui.download
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -15,12 +16,13 @@ import android.widget.Toast
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.data.FileType
@@ -28,13 +30,16 @@ import me.rhunk.snapenhance.download.data.PendingDownload
import me.rhunk.snapenhance.download.enums.DownloadStage
import me.rhunk.snapenhance.util.snap.PreviewUtils
import java.io.File
import java.io.FileInputStream
import java.net.URL
import kotlin.concurrent.thread
import kotlin.coroutines.coroutineContext
class DownloadListAdapter(
private val activity: DownloadManagerActivity,
private val downloadList: MutableList<PendingDownload>
): Adapter<DownloadListAdapter.ViewHolder>() {
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val previewJobs = mutableMapOf<Int, Job>()
inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
@@ -62,32 +67,59 @@ class DownloadListAdapter(
return downloadList.size
}
@OptIn(DelicateCoroutinesApi::class)
private fun handlePreview(download: PendingDownload, holder: ViewHolder) {
download.outputFile?.let { File(it) }?.takeIf { it.exists() }?.let {
GlobalScope.launch(Dispatchers.IO) {
val previewBitmap = PreviewUtils.createPreviewFromFile(it)?.let { preview ->
val offsetY = (preview.height / 2 - holder.viewHeight / 2).coerceAtLeast(0)
Bitmap.createScaledBitmap(
Bitmap.createBitmap(preview, 0, offsetY,
preview.width.coerceAtMost(holder.viewWidth),
preview.height.coerceAtMost(holder.viewHeight)
),
holder.viewWidth,
holder.viewHeight,
false
)
}?: return@launch
if (coroutineContext.job.isCancelled) return@launch
Handler(holder.view.context.mainLooper).post {
holder.view.background = RoundedBitmapDrawableFactory.create(holder.view.context.resources, previewBitmap).also {
it.cornerRadius = holder.radius.toFloat()
@SuppressLint("Recycle")
private suspend fun handlePreview(download: PendingDownload, holder: ViewHolder) {
download.outputFile?.let {
val uri = Uri.parse(it)
runCatching {
if (uri.scheme == "content") {
val fileType = activity.contentResolver.openInputStream(uri)!!.use { stream ->
FileType.fromInputStream(stream)
}
fileType to activity.contentResolver.openInputStream(uri)
} else {
FileType.fromFile(File(it)) to FileInputStream(it)
}
}.getOrNull()
}?.also { (fileType, assetStream) ->
val previewBitmap = assetStream?.use { stream ->
//don't preview files larger than 30MB
if (stream.available() > 30 * 1024 * 1024) return@also
val tempFile = File.createTempFile("preview", ".${fileType.fileExtension}")
tempFile.outputStream().use { output ->
stream.copyTo(output)
}
runCatching {
PreviewUtils.createPreviewFromFile(tempFile)?.let { preview ->
val offsetY = (preview.height / 2 - holder.viewHeight / 2).coerceAtLeast(0)
Bitmap.createScaledBitmap(
Bitmap.createBitmap(
preview, 0, offsetY,
preview.width.coerceAtMost(holder.viewWidth),
preview.height.coerceAtMost(holder.viewHeight)
),
holder.viewWidth,
holder.viewHeight,
false
)
}
}.onFailure {
Logger.error("failed to create preview $fileType", it)
}.also {
tempFile.delete()
}.getOrNull()
} ?: return@also
if (coroutineContext.job.isCancelled) return@also
Handler(holder.view.context.mainLooper).post {
holder.view.background = RoundedBitmapDrawableFactory.create(
holder.view.context.resources,
previewBitmap
).also {
it.cornerRadius = holder.radius.toFloat()
}
}.also { job ->
previewJobs[holder.hashCode()] = job
}
}
}
@@ -96,7 +128,11 @@ class DownloadListAdapter(
holder.status.text = download.downloadStage.toString()
holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background)
handlePreview(download, holder)
coroutineScope.launch {
withTimeout(2000) {
handlePreview(download, holder)
}
}
val isSaved = download.downloadStage == DownloadStage.SAVED
//if the download is in progress, the user can cancel it
@@ -129,7 +165,7 @@ class DownloadListAdapter(
holder.bitmojiIcon.setImageResource(R.drawable.bitmoji_blank)
pendingDownload.iconUrl?.let { url ->
pendingDownload.metadata.iconUrl?.let { url ->
thread(start = true) {
runCatching {
val iconBitmap = URL(url).openStream().use {
@@ -145,12 +181,12 @@ class DownloadListAdapter(
holder.title.visibility = View.GONE
holder.subtitle.visibility = View.GONE
pendingDownload.mediaDisplayType?.let {
pendingDownload.metadata.mediaDisplayType?.let {
holder.title.text = it
holder.title.visibility = View.VISIBLE
}
pendingDownload.mediaDisplaySource?.let {
pendingDownload.metadata.mediaDisplaySource?.let {
holder.subtitle.text = it
holder.subtitle.visibility = View.VISIBLE
}
@@ -165,13 +201,38 @@ class DownloadListAdapter(
}
pendingDownload.outputFile?.let {
val file = File(it)
if (!file.exists()) {
Toast.makeText(holder.view.context, activity.translation["file_not_found_toast"], Toast.LENGTH_SHORT).show()
fun showFileNotFound() {
Toast.makeText(holder.view.context, SharedContext.translation["download_manager_activity.file_not_found_toast"], Toast.LENGTH_SHORT).show()
}
val uri = Uri.parse(it)
val fileType = runCatching {
if (uri.scheme == "content") {
activity.contentResolver.openInputStream(uri)?.use { input ->
FileType.fromInputStream(input)
} ?: run {
showFileNotFound()
return@setOnClickListener
}
} else {
val file = File(it)
if (!file.exists()) {
showFileNotFound()
return@setOnClickListener
}
FileType.fromFile(file)
}
}.onFailure { exception ->
Logger.error("Failed to open file", exception)
}.getOrDefault(FileType.UNKNOWN)
if (fileType == FileType.UNKNOWN) {
showFileNotFound()
return@setOnClickListener
}
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(Uri.parse(it), FileType.fromFile(File(it)).mimeType)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
intent.setDataAndType(uri, fileType.mimeType)
holder.view.context.startActivity(intent)
}
}

View File

@@ -17,8 +17,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.bridge.TranslationWrapper
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.bridge.wrapper.TranslationWrapper
import me.rhunk.snapenhance.download.data.PendingDownload
class DownloadManagerActivity : Activity() {
@@ -133,7 +133,7 @@ class DownloadManagerActivity : Activity() {
if (lastVisibleItemPosition == fetchedDownloadTasks.size - 1 && !isLoading) {
isLoading = true
SharedContext.downloadTaskManager.queryTasks(fetchedDownloadTasks.last().id, filter = listFilter).forEach {
SharedContext.downloadTaskManager.queryTasks(fetchedDownloadTasks.last().downloadId, filter = listFilter).forEach {
fetchedDownloadTasks.add(it.value)
adapter?.notifyItemInserted(fetchedDownloadTasks.size - 1)
}

View File

@@ -7,13 +7,15 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Button
import android.widget.LinearLayout
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
class ChatActionMenu : AbstractMenu() {
@@ -23,7 +25,7 @@ class ChatActionMenu : AbstractMenu() {
return false
}
@SuppressLint("SetTextI18n")
@SuppressLint("SetTextI18n", "DiscouragedApi")
fun inject(viewGroup: ViewGroup) {
val parent = viewGroup.parent.parent as ViewGroup
if (wasInjectedView(parent)) return
@@ -41,17 +43,64 @@ class ChatActionMenu : AbstractMenu() {
)
}
val defaultGap = viewGroup.resources.getDimensionPixelSize(
viewGroup.resources.getIdentifier(
"default_gap",
"dimen",
Constants.SNAPCHAT_PACKAGE_NAME
)
)
val chatActionMenuItemMargin = viewGroup.resources.getDimensionPixelSize(
viewGroup.resources.getIdentifier(
"chat_action_menu_item_margin",
"dimen",
Constants.SNAPCHAT_PACKAGE_NAME
)
)
val actionMenuItemHeight = viewGroup.resources.getDimensionPixelSize(
viewGroup.resources.getIdentifier(
"action_menu_item_height",
"dimen",
Constants.SNAPCHAT_PACKAGE_NAME
)
)
val buttonContainer = LinearLayout(viewGroup.context).apply layout@{
orientation = LinearLayout.VERTICAL
layoutParams = MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
ViewAppearanceHelper.applyTheme(this@layout, parent.width, true)
setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap)
}
}
val injectButton = { button: Button ->
ViewAppearanceHelper.applyTheme(button, parent.width, hasRadius = true)
if (buttonContainer.childCount > 0) {
buttonContainer.addView(View(viewGroup.context).apply {
layoutParams = MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
height = 1
}
setBackgroundColor(0x1A000000)
})
}
ViewAppearanceHelper.applyTheme(button, parent.width, true)
with(button) {
layoutParams = MarginLayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(40, 0, 40, 15)
height = actionMenuItemHeight + defaultGap
}
parent.addView(this)
buttonContainer.addView(this)
}
}
@@ -91,5 +140,7 @@ class ChatActionMenu : AbstractMenu() {
}
})
}
parent.addView(buttonContainer)
}
}

View File

@@ -1,7 +1,6 @@
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.content.res.Resources
@@ -32,8 +31,8 @@ import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload
import me.rhunk.snapenhance.features.impl.spying.StealthMode
import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
import java.net.HttpURLConnection
import java.net.URL
@@ -73,7 +72,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
val finalIcon = icon
context.runOnUiThread {
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
val builder = AlertDialog.Builder(context.mainActivity)
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
builder.setIcon(finalIcon)
builder.setTitle(profile.displayName ?: profile.username)
@@ -168,7 +167,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
//alert dialog
val builder = AlertDialog.Builder(context.mainActivity)
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
builder.setTitle(context.translation["conversation_preview.title"])
builder.setMessage(messageBuilder.toString())
builder.setPositiveButton(
@@ -200,10 +199,20 @@ class FriendFeedInfoMenu : AbstractMenu() {
return BitmapDrawable(context.resources, bitmap)
}
private fun getCurrentConversationId(): Pair<String, String?> {
private fun getCurrentConversationInfo(): Pair<String, String?> {
val messaging = context.feature(Messaging::class)
val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString()
//mapped conversation fetch (may not work with legacy sc versions)
messaging.lastFetchGroupConversationUUID?.let {
context.database.getFriendFeedInfoByConversationId(it.toString())?.let { friendFeedInfo ->
val participantSize = friendFeedInfo.participantsSize
return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null
}
throw IllegalStateException("No conversation found")
}
//old conversation fetch
val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) {
val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found")
conversation.client_conversation_id!!.trim().lowercase()
@@ -211,7 +220,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
messaging.lastFetchConversationUUID.toString()
}
return Pair(conversationId, focusedConversationTargetUser)
return conversationId to focusedConversationTargetUser
}
private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) {
@@ -234,7 +243,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
val friendFeedMenuOptions = context.config.options(ConfigProperty.FRIEND_FEED_MENU_BUTTONS)
if (friendFeedMenuOptions.none { it.value }) return
val (conversationId, targetUser) = getCurrentConversationId()
val (conversationId, targetUser) = getCurrentConversationInfo()
if (!context.config.bool(ConfigProperty.ENABLE_FRIEND_FEED_MENU_BAR)) {
//preview button

View File

@@ -28,14 +28,6 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME))
}
private val fetchConversationHooks = mutableSetOf<Unhook>()
private fun unhookFetchConversation() {
fetchConversationHooks.let {
it.removeIf { hook -> hook.unhook() ; true}
}
}
@SuppressLint("ResourceType")
override fun asyncOnActivityCreate() {
friendFeedInfoMenu.context = context
@@ -86,19 +78,25 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
}
//TODO: inject in group chat menus
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu) {
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchGroupConversationUUID != null) {
val injectedLayout = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addView(childView)
addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}
override fun onViewDetachedFromWindow(v: View) {
messaging.lastFetchGroupConversationUUID = null
}
})
}
fun injectView() {
val viewList = mutableListOf<View>()
val viewList = mutableListOf<View>()
context.runOnUiThread {
friendFeedInfoMenu.inject(injectedLayout) { view ->
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 10, 0, 10)
setMargins(0, 3, 0, 3)
}
viewList.add(view)
}

View File

@@ -11,7 +11,7 @@ import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme
import me.rhunk.snapenhance.ui.ViewAppearanceHelper.applyTheme
@SuppressLint("DiscouragedApi")
class OperaContextActionMenu : AbstractMenu() {

View File

@@ -18,7 +18,7 @@ import me.rhunk.snapenhance.config.impl.ConfigStateSelection
import me.rhunk.snapenhance.config.impl.ConfigStateValue
import me.rhunk.snapenhance.config.impl.ConfigStringValue
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
class SettingsMenu : AbstractMenu() {
@SuppressLint("ClickableViewAccessibility")
@@ -48,14 +48,14 @@ class SettingsMenu : AbstractMenu() {
if (property.disableValueLocalization) {
it
} else {
context.translation["option.property." + property.translationKey + "." + it]
context.translation[property.getOptionTranslationKey(it)]
}
}
})
}
val textEditor: ((String) -> Unit) -> Unit = { updateValue ->
val builder = AlertDialog.Builder(context.mainActivity!!)
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
builder.setTitle(propertyName)
val input = EditText(context.androidContext)
@@ -121,13 +121,13 @@ class SettingsMenu : AbstractMenu() {
updateLocalizedText(button, property.valueContainer.value())
button.setOnClickListener {_ ->
val builder = AlertDialog.Builder(context.mainActivity!!)
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
builder.setTitle(propertyName)
builder.setSingleChoiceItems(
property.valueContainer.keys().toTypedArray().map {
if (property.disableValueLocalization) it
else context.translation["option.property." + property.translationKey + "." + it]
else context.translation[property.getOptionTranslationKey(it)]
}.toTypedArray(),
property.valueContainer.keys().indexOf(property.valueContainer.value())
) { _, which ->
@@ -148,7 +148,7 @@ class SettingsMenu : AbstractMenu() {
updateButtonText(button, "(${property.valueContainer.value().count { it.value }})")
button.setOnClickListener {_ ->
val builder = AlertDialog.Builder(context.mainActivity!!)
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
builder.setTitle(propertyName)
val sortedStates = property.valueContainer.value().toSortedMap()
@@ -156,7 +156,7 @@ class SettingsMenu : AbstractMenu() {
builder.setMultiChoiceItems(
sortedStates.toSortedMap().map {
if (property.disableValueLocalization) it.key
else context.translation["option.property." + property.translationKey + "." + it.key]
else context.translation[property.getOptionTranslationKey(it.key)]
}.toTypedArray(),
sortedStates.map { it.value }.toBooleanArray()
) { _, which, isChecked ->

View File

@@ -0,0 +1,111 @@
package me.rhunk.snapenhance.ui.spoof
import android.app.Activity
import android.content.res.ColorStateList
import android.os.Bundle
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.Switch
import android.widget.TextView
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.SharedContext
import me.rhunk.snapenhance.config.ConfigCategory
import me.rhunk.snapenhance.config.impl.ConfigIntegerValue
import me.rhunk.snapenhance.config.impl.ConfigStateValue
import me.rhunk.snapenhance.config.impl.ConfigStringValue
import me.rhunk.snapenhance.ui.ItemHelper
class DeviceSpooferActivity: Activity() {
private val itemHelper = ItemHelper(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SharedContext.ensureInitialized(this)
setContentView(R.layout.device_spoofer_activity)
findViewById<TextView>(R.id.title).text = "Device Spoofer"
findViewById<ImageButton>(R.id.back_button).setOnClickListener { finish() }
val propertyListLayout = findViewById<ViewGroup>(R.id.spoof_property_list)
SharedContext.config.entries().filter { it.key.category == ConfigCategory.DEVICE_SPOOFER }.forEach { (property, value) ->
val configItem = layoutInflater.inflate(R.layout.config_activity_item, propertyListLayout, false)
val propertyName = SharedContext.translation["property.${property.translationKey}.name"]
fun addSeparator() {
//add separator
propertyListLayout.addView(View(this).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1)
setBackgroundColor(getColor(R.color.tertiaryBackground))
})
}
configItem.findViewById<TextView>(R.id.name).text = propertyName
configItem.findViewById<TextView>(R.id.description).also {
it.text = SharedContext.translation["property.${property.translationKey}.description"]
it.visibility = if (it.text.isEmpty()) View.GONE else View.VISIBLE
}
fun addValueView(view: View) {
configItem.findViewById<ViewGroup>(R.id.value).addView(view)
}
when (value) {
is ConfigStateValue -> {
val switch = Switch(this)
switch.isChecked = value.value()
switch.trackTintList = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
),
intArrayOf(
switch.highlightColor,
getColor(R.color.tertiaryBackground)
)
)
switch.setOnCheckedChangeListener { _, isChecked ->
value.writeFrom(isChecked.toString())
}
configItem.setOnClickListener { switch.toggle() }
addValueView(switch)
}
is ConfigStringValue, is ConfigIntegerValue -> {
val textView = itemHelper.createTranslatedTextView(property, shouldTranslatePropertyValue = false).also {
it.text = value.value().toString()
}
configItem.setOnClickListener {
if (value is ConfigIntegerValue) {
itemHelper.askForValue(property, InputType.TYPE_CLASS_NUMBER) {
try {
value.writeFrom(it)
textView.text = value.value().toString()
} catch (e: NumberFormatException) {
itemHelper.longToast(SharedContext.translation["config_activity.invalid_number_toast"], this)
}
}
return@setOnClickListener
}
itemHelper.askForValue(property, InputType.TYPE_CLASS_TEXT) {
value.writeFrom(it)
textView.text = value.value().toString()
}
}
addValueView(textView)
}
}
propertyListLayout.addView(configItem)
addSeparator()
}
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onBackPressed() {
super.onBackPressed()
finish()
}
}

View File

@@ -0,0 +1,5 @@
package me.rhunk.snapenhance.util
import android.content.Intent
typealias ActivityResultCallback = (requestCode: Int, resultCode: Int, data: Intent?) -> Unit

View File

@@ -0,0 +1,13 @@
package me.rhunk.snapenhance.util
import android.content.pm.PackageManager
import android.content.pm.PackageManager.ApplicationInfoFlags
import android.os.Build
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getApplicationInfo(packageName, ApplicationInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
getApplicationInfo(packageName, flags)
}

View File

@@ -20,6 +20,7 @@ import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.database.objects.FriendFeedInfo
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.util.getApplicationInfoCompat
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
@@ -190,7 +191,7 @@ class MessageExporter(
runCatching {
ZipFile(
context.androidContext.packageManager.getApplicationInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA).publicSourceDir
context.androidContext.packageManager.getApplicationInfoCompat(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA).publicSourceDir
).use { apkFile ->
//export rawinflate.js
apkFile.getEntry("assets/web/rawinflate.js").let { entry ->

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.util.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import java.io.ByteArrayInputStream
@@ -15,9 +16,6 @@ import java.io.InputStream
import java.util.concurrent.Executors
import java.util.zip.ZipInputStream
enum class MediaType {
ORIGINAL, OVERLAY
}
object MediaDownloaderHelper {
fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? {
@@ -32,7 +30,7 @@ object MediaDownloaderHelper {
}
}
fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<MediaType, ByteArray> {
fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<SplitMediaAssetType, ByteArray> {
val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info")
val content = decryptionCallback(inputStream).readBytes()
val fileType = FileType.fromByteArray(content)
@@ -55,10 +53,10 @@ object MediaDownloaderHelper {
}
videoData ?: throw FileNotFoundException("Unable to find video file in zip file")
overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file")
return mapOf(MediaType.ORIGINAL to videoData, MediaType.OVERLAY to overlayData)
return mapOf(SplitMediaAssetType.ORIGINAL to videoData, SplitMediaAssetType.OVERLAY to overlayData)
}
return mapOf(MediaType.ORIGINAL to content)
return mapOf(SplitMediaAssetType.ORIGINAL to content)
}

View File

@@ -8,7 +8,6 @@ import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
import me.rhunk.snapenhance.data.FileType
import java.io.File
import kotlin.math.roundToInt
object PreviewUtils {
fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? {

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:background="@color/primaryBackground"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include
android:id="@+id/title_bar"
layout="@layout/activity_default_header" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:id="@+id/spoof_property_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -1,10 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
}
tasks.register('clean', Delete) {
delete rootProject.buildDir
}

Some files were not shown because too many files have changed in this diff Show More