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:
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
@@ -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 }}"
|
||||
|
||||
110
app/build.gradle
110
app/build.gradle
@@ -1,110 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
def appVersionName = "1.1.0"
|
||||
def appVersionCode = 7
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
buildToolsVersion = "33.0.2"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "me.rhunk.snapenhance"
|
||||
minSdk 28
|
||||
targetSdk 33
|
||||
versionCode appVersionCode
|
||||
versionName appVersionName
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.outputs.configureEach {
|
||||
outputFileName = "app-${appVersionName}-${variant.flavorName}.apk"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "release"
|
||||
|
||||
productFlavors {
|
||||
armv8 {
|
||||
getIsDefault().set(true)
|
||||
ndk {
|
||||
//noinspection ChromeOsAbiSupport
|
||||
abiFilters "arm64-v8a"
|
||||
}
|
||||
dimension "release"
|
||||
}
|
||||
armv7 {
|
||||
ndk {
|
||||
//noinspection ChromeOsAbiSupport
|
||||
abiFilters "armeabi-v7a"
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'lib/armeabi-v7a/*_neon.so'
|
||||
}
|
||||
dimension "release"
|
||||
}
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
namespace 'me.rhunk.snapenhance'
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
//auto install for debug purpose
|
||||
getTasks().getByPath(":app:assembleArmv8Debug").doLast {
|
||||
def apkDebugFile = android.applicationVariants.find { it.buildType.name == "debug" && it.flavorName == "armv8" }.outputs[0].outputFile
|
||||
try {
|
||||
println "Killing Snapchat"
|
||||
exec {
|
||||
commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android"
|
||||
}
|
||||
println "Installing debug build"
|
||||
exec() {
|
||||
commandLine "adb", "install", "-r", "-d", apkDebugFile.absolutePath
|
||||
}
|
||||
println "Starting Snapchat"
|
||||
exec {
|
||||
commandLine "adb", "shell", "am", "start", "com.snapchat.android"
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
println "Failed to install debug build"
|
||||
t.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task getVersion {
|
||||
doLast {
|
||||
def version = new File('app/build/version.txt')
|
||||
version.text = android.defaultConfig.versionName
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||
|
||||
compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1.LTS'
|
||||
implementation 'org.osmdroid:osmdroid-android:6.1.16'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
}
|
||||
119
app/build.gradle.kts
Normal file
119
app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
21
app/proguard-rules.pro
vendored
@@ -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
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
126
app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt
Normal file
126
app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeClient.kt
Normal 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)
|
||||
}
|
||||
105
app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
Normal file
105
app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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),
|
||||
@@ -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) }
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.bridge
|
||||
package me.rhunk.snapenhance.bridge.wrapper
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.rhunk.snapenhance.download.data
|
||||
|
||||
enum class SplitMediaAssetType {
|
||||
ORIGINAL, OVERLAY
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/src/main/kotlin/me/rhunk/snapenhance/ui/ItemHelper.kt
Normal file
85
app/src/main/kotlin/me/rhunk/snapenhance/ui/ItemHelper.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.rhunk.snapenhance.util
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
typealias ActivityResultCallback = (requestCode: Int, resultCode: Int, data: Intent?) -> Unit
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
27
app/src/main/res/layout/device_spoofer_activity.xml
Normal file
27
app/src/main/res/layout/device_spoofer_activity.xml
Normal 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>
|
||||
10
build.gradle
10
build.gradle
@@ -1,10 +0,0 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.0.2' apply false
|
||||
id 'com.android.library' version '8.0.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
|
||||
}
|
||||
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user