feat(composer_hooks): ts modules

This commit is contained in:
rhunk 2024-05-24 02:06:01 +02:00
parent 807ce1b6e7
commit dd8590d274
19 changed files with 399 additions and 226 deletions

View File

@ -26,6 +26,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleArmv8Debug run: ./gradlew assembleArmv8Debug
@ -70,6 +73,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleArmv7Debug run: ./gradlew assembleArmv7Debug
@ -114,6 +120,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleAllDebug run: ./gradlew assembleAllDebug

View File

@ -30,6 +30,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleArmv8Release run: ./gradlew assembleArmv8Release
@ -73,6 +76,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleArmv7Release run: ./gradlew assembleArmv7Release
@ -116,6 +122,9 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Build - name: Build
run: ./gradlew assembleAllRelease run: ./gradlew assembleAllRelease

1
composer/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

34
composer/build.gradle.kts Normal file
View File

@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinAndroid)
}
android {
namespace = rootProject.ext["applicationId"].toString() + ".composer"
compileSdk = 34
sourceSets {
getByName("main") {
assets.srcDirs("build/assets")
}
}
}
task("compileTypeScript") {
doLast {
project.exec {
commandLine("npx", "--yes", "tsc", "--project", "tsconfig.json")
}
project.exec {
commandLine("npx", "--yes", "rollup", "--config", "rollup.config.js", "--bundleConfigAsCjs")
}
project.copy {
from("build/loader.js")
into("build/assets/composer")
}
}
}
tasks.named("preBuild").configure {
dependsOn("compileTypeScript")
}

View File

@ -0,0 +1,7 @@
export default {
input: "./build/typescript/main.js",
output: {
file: "./build/loader.js",
format: "iife",
}
};

View File

@ -0,0 +1,4 @@
export const jsx = require('composer_core/src/JSX').jsx;
export const assetCatalog = require("composer_core/src/AssetCatalog")
export const style = require("composer_core/src/Style");
export const colors = require("coreui/src/styles/semanticColors");

View File

@ -0,0 +1,21 @@
import { Config, FriendInfo } from "./types";
declare var _getImportsFunctionName: string;
const remoteImports = require('composer_core/src/DeviceBridge')[_getImportsFunctionName]();
function callRemoteFunction(method: string, ...args: any[]): any | null {
return remoteImports[method](...args);
}
export const log = (logLevel: string, message: string) => callRemoteFunction("log", logLevel, message);
export const getConfig = () => callRemoteFunction("getConfig") as Config;
export const downloadLastOperaMedia = (isLongPress: boolean) => callRemoteFunction("downloadLastOperaMedia", isLongPress);
export function getFriendInfoByUsername(username: string): FriendInfo | null {
const friendInfo = callRemoteFunction("getFriendInfoByUsername", username);
if (!friendInfo) return null;
return JSON.parse(friendInfo);
}

View File

@ -0,0 +1,36 @@
import { getConfig, log } from "./imports";
import { Module } from "./types";
import operaDownloadButton from "./modules/operaDownloadButton";
import firstCreatedUsername from "./modules/firstCreatedUsername";
import bypassCameraRollSelectionLimit from "./modules/bypassCameraRollSelectionLimit";
try {
const config = getConfig();
if (config.composerLogs) {
["log", "error", "warn", "info", "debug"].forEach(method => {
console[method] = (...args: any) => log(method, Array.from(args).join(" "));
})
}
const modules: Module[] = [
operaDownloadButton,
firstCreatedUsername,
bypassCameraRollSelectionLimit
];
modules.forEach(module => {
if (!module.enabled(config)) return
try {
module.init();
} catch (e) {
console.error(`failed to initialize module ${module.name}`, e, e.stack);
}
});
console.log("composer modules loaded!");
} catch (e) {
log("error", "Failed to load composer modules\n" + e + "\n" + e.stack)
}

View File

@ -0,0 +1,19 @@
import { defineModule } from "../types";
import { interceptComponent } from "../utils";
export default defineModule({
name: "Bypass Camera Roll Selection Limit",
enabled: config => config.bypassCameraRollLimit,
init() {
interceptComponent(
'memories_ui/src/clickhandlers/MultiSelectClickHandler',
'MultiSelectClickHandler',
{
"<init>": (args: any[], superCall: () => void) => {
args[1].selectionLimit = 9999999;
superCall();
}
}
)
}
});

View File

@ -0,0 +1,28 @@
import { defineModule } from "../types";
import { getFriendInfoByUsername } from "../imports";
import { interceptComponent } from "../utils";
export default defineModule({
name: "Show First Created Username",
enabled: config => config.showFirstCreatedUsername,
init() {
interceptComponent(
'common_profile/src/identity/ProfileIdentityView',
'ProfileIdentityView',
{
onRender: (component: any, _args: any[], render: () => void) => {
if (component.viewModel) {
let userInfo = getFriendInfoByUsername(component.viewModel.username);
if (userInfo) {
let firstCreatedUsername = userInfo.username.split("|")[0];
if (firstCreatedUsername != component.viewModel.username) {
component.viewModel.username += " (" + firstCreatedUsername + ")";
}
}
}
render();
}
}
)
}
});

View File

@ -0,0 +1,35 @@
import { assetCatalog, jsx, style } from "../composer"
import { defineModule } from "../types"
import { downloadLastOperaMedia } from "../imports"
import { interceptComponent } from "../utils"
export default defineModule({
name: "Opera Download Button",
enabled: config => config.operaDownloadButton,
init() {
const downloadIcon = assetCatalog.loadCatalog("share_sheet/res").downloadIcon
interceptComponent(
'context_chrome_header/src/ChromeHeaderRenderer',
'ChromeHeaderRenderer',
{
onRenderBaseHeader: (_component: any, _args: any[], render: () => void) => {
render()
jsx.beginRender(jsx.makeNodePrototype("image"))
jsx.setAttributeStyle("style", new style.Style({
height: 32,
marginTop: 4,
marginLeft: 8,
marginRight: 12,
objectFit: "contain",
tint: "white"
}))
jsx.setAttribute("src", downloadIcon)
jsx.setAttributeFunction("onTap", () => downloadLastOperaMedia(false))
jsx.setAttributeFunction("onLongPress", () => downloadLastOperaMedia(true))
jsx.endRender()
}
}
)
}
})

View File

@ -0,0 +1,44 @@
export interface Config {
readonly operaDownloadButton: boolean
readonly bypassCameraRollLimit: boolean
readonly showFirstCreatedUsername: boolean
readonly composerLogs: boolean
}
export interface FriendInfo {
readonly id: number
readonly lastModifiedTimestamp: number
readonly username: string
readonly userId: string
readonly displayName: string
readonly bitmojiAvatarId: string
readonly bitmojiSelfieId: string
readonly bitmojiSceneId: string
readonly bitmojiBackgroundId: string
readonly friendmojis: string
readonly friendmojiCategories: string
readonly snapScore: number
readonly birthday: number
readonly addedTimestamp: number
readonly reverseAddedTimestamp: number
readonly serverDisplayName: string
readonly streakLength: number
readonly streakExpirationTimestamp: number
readonly reverseBestFriendRanking: number
readonly isPinnedBestFriend: number
readonly plusBadgeVisibility: number
readonly usernameForSorting: string
readonly friendLinkType: number
readonly postViewEmoji: string
readonly businessCategory: number
}
export interface Module {
readonly name: string
enabled: (config: Config) => boolean
init: () => void
}
export function defineModule<T extends Module>(module: T & Record<string, any>): T {
return module
}

View File

@ -0,0 +1,57 @@
export function dumpObject(obj: any, indent = 0) {
if (typeof obj !== "object") return console.log(obj);
let prefix = ""
for (let i = 0; i < indent; i++) {
prefix += " ";
}
for (let key of Object.keys(obj)) {
try {
console.log(prefix, key, typeof obj[key], obj[key]);
if (key == "renderer") continue
if (typeof obj[key] === "object" && indent < 10) dumpObject(obj[key], indent + 1);
} catch (e) {}
}
}
export function proxyProperty(module: any, functionName: string, handler: any) {
if (!module || !module[functionName]) {
console.warn("Function not found", functionName);
return;
}
module[functionName] = new Proxy(module[functionName], {
apply: (a, b, c) => handler(a, b, c),
construct: (a, b, c) => handler(a, b, c)
});
}
export function interceptComponent(moduleName: string, className: string, functions: any) {
proxyProperty(require(moduleName), className, (target: any, args: any[], newTarget: any) => {
let initProxy = functions["<init>"]
let component: any;
if (initProxy) {
initProxy(args, (newArgs: any[]) => {
component = Reflect.construct(target, newArgs || args, newTarget);
});
} else {
component = Reflect.construct(target, args, newTarget);
}
for (let funcName of Object.keys(functions)) {
if (funcName == "<init>" || !component[funcName]) continue
proxyProperty(component, funcName, (target: any, thisArg: any, argumentsList: any[]) => {
let result: any;
try {
functions[funcName](component, argumentsList, (newArgs: any[]) => {
result = Reflect.apply(target, thisArg, newArgs || argumentsList);
});
} catch (e) {
console.error("Error in", funcName, e);
}
return result;
});
}
return component;
})
}

8
composer/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"outDir": "build/typescript",
"target": "ES6",
"typeRoots": ["types/*"]
},
"include": ["./src/main/ts/**/*", "./types/**/*"]
}

1
composer/types/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare function require(module: string): any;

View File

@ -41,6 +41,7 @@ dependencies {
implementation(project(":common")) implementation(project(":common"))
implementation(project(":mapper")) implementation(project(":mapper"))
implementation(project(":native")) implementation(project(":native"))
implementation(project(":composer"))
implementation(libs.androidx.activity.ktx) implementation(libs.androidx.activity.ktx)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))

View File

@ -1,138 +0,0 @@
const config = callExport("getConfig");
if (config.composerLogs) {
["log", "error", "warn", "info", "debug"].forEach(method => {
console[method] = (...args) => callExport("log", method, Array.from(args).join(" "));
})
console.stacktrace = () => new Error().stack;
console.info("loader.js loaded");
}
// Composer imports
const jsx = require('composer_core/src/JSX').jsx;
const assetCatalog = require("composer_core/src/AssetCatalog")
const style = require("composer_core/src/Style");
const colors = require("coreui/src/styles/semanticColors");
function dumpObject(obj, indent = 0) {
if (typeof obj !== "object") return console.log(obj);
let prefix = ""
for (let i = 0; i < indent; i++) {
prefix += " ";
}
for (let key of Object.keys(obj)) {
try {
console.log(prefix, key, typeof obj[key], obj[key]);
if (key == "renderer") continue
if (typeof obj[key] === "object" && indent < 10) dumpObject(obj[key], indent + 1);
} catch (e) {
}
}
}
function proxyProperty(module, functionName, handler) {
if (!module || !module[functionName]) {
console.warn("Function not found", functionName);
return;
}
module[functionName] = new Proxy(module[functionName], {
apply: (a, b, c) => handler(a, b, c),
construct: (a, b, c) => handler(a, b, c)
});
}
function interceptComponent(moduleName, className, functions) {
proxyProperty(require(moduleName), className, (target, args, newTarget) => {
let initProxy = functions["<init>"]
let component;
if (initProxy) {
initProxy(args, (newArgs) => {
component = Reflect.construct(target, newArgs || args, newTarget);
});
} else {
component = Reflect.construct(target, args, newTarget);
}
for (let funcName of Object.keys(functions)) {
if (funcName == "<init>" || !component[funcName]) continue
proxyProperty(component, funcName, (target, thisArg, argumentsList) => {
let result;
try {
functions[funcName](component, argumentsList, (newArgs) => {
result = Reflect.apply(target, thisArg, newArgs || argumentsList);
});
} catch (e) {
console.error("Error in", funcName, e);
}
return result;
});
}
return component;
})
}
if (config.bypassCameraRollLimit) {
interceptComponent(
'memories_ui/src/clickhandlers/MultiSelectClickHandler',
'MultiSelectClickHandler',
{
"<init>": (args, superCall) => {
args[1].selectionLimit = 9999999;
superCall();
}
}
)
}
if (config.operaDownloadButton) {
const downloadIcon = assetCatalog.loadCatalog("share_sheet/res").downloadIcon
interceptComponent(
'context_chrome_header/src/ChromeHeaderRenderer',
'ChromeHeaderRenderer',
{
onRenderBaseHeader: (component, args, render) => {
render()
jsx.beginRender(jsx.makeNodePrototype("image"))
jsx.setAttributeStyle("style", new style.Style({
height: 32,
marginTop: 4,
marginLeft: 8,
marginRight: 12,
objectFit: "contain",
tint: "white"
}))
jsx.setAttribute("src", downloadIcon)
jsx.setAttributeFunction("onTap", () => callExport("downloadLastOperaMedia", false))
jsx.setAttributeFunction("onLongPress", () => callExport("downloadLastOperaMedia", true))
jsx.endRender()
}
}
)
}
if (config.showFirstCreatedUsername) {
interceptComponent(
'common_profile/src/identity/ProfileIdentityView',
'ProfileIdentityView',
{
onRender: (component, _, render) => {
if (component.viewModel) {
let userInfo = callExport("getFriendInfoByUsername", component.viewModel.username);
if (userInfo) {
let userInfoJson = JSON.parse(userInfo);
let firstCreatedUsername = userInfoJson.username.split("|")[0];
if (firstCreatedUsername != component.viewModel.username) {
component.viewModel.username += " (" + firstCreatedUsername + ")";
}
}
}
render();
}
}
)
}

View File

@ -44,7 +44,7 @@ import kotlin.random.Random
class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.INIT_SYNC) { class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.INIT_SYNC) {
private val config by lazy { context.config.experimental.nativeHooks.composerHooks } private val config by lazy { context.config.experimental.nativeHooks.composerHooks }
private val exportedFunctionName = Random.nextInt().absoluteValue.toString(16) private val getImportsFunctionName = Random.nextLong().absoluteValue.toString(16)
private val composerConsole by lazy { private val composerConsole by lazy {
createComposeAlertDialog(context.mainActivity!!) { createComposeAlertDialog(context.mainActivity!!) {
@ -126,7 +126,7 @@ class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.INI
} }
} }
private fun newComposerFunction(block: (composerMarshaller: ComposerMarshaller) -> Boolean): Any { private fun newComposerFunction(block: ComposerMarshaller.() -> Boolean): Any? {
val composerFunctionClass = findClass("com.snap.composer.callable.ComposerFunction") val composerFunctionClass = findClass("com.snap.composer.callable.ComposerFunction")
return Proxy.newProxyInstance( return Proxy.newProxyInstance(
composerFunctionClass.classLoader, composerFunctionClass.classLoader,
@ -137,101 +137,97 @@ class ComposerHooks: Feature("ComposerHooks", loadParams = FeatureLoadParams.INI
} }
} }
private fun getConfig(): Map<String, Any> {
return mapOf<String, Any>(
"operaDownloadButton" to context.config.downloader.operaDownloadButton.get(),
"bypassCameraRollLimit" to config.bypassCameraRollLimit.get(),
"showFirstCreatedUsername" to config.showFirstCreatedUsername.get(),
"composerConsole" to config.composerConsole.get(),
"composerLogs" to config.composerLogs.get()
)
}
private fun handleExportCall(composerMarshaller: ComposerMarshaller): Boolean {
val argc = composerMarshaller.getSize()
if (argc < 1) return false
val action = composerMarshaller.getUntyped(0) as? String ?: return false
when (action) {
"getConfig" -> composerMarshaller.pushUntyped(getConfig())
"showToast" -> {
if (argc < 2) return false
context.shortToast(composerMarshaller.getUntyped(1) as? String ?: return false)
}
"downloadLastOperaMedia" -> context.feature(MediaDownloader::class).downloadLastOperaMediaAsync(composerMarshaller.getUntyped(1) == true)
"getFriendInfoByUsername" -> {
if (argc < 2) return false
val username = composerMarshaller.getUntyped(1) as? String ?: return false
runCatching {
composerMarshaller.pushUntyped(context.database.getFriendInfoByUsername(username)?.let {
context.gson.toJson(it)
})
}.onFailure {
composerMarshaller.pushUntyped(null)
}
}
"log" -> {
if (argc < 3) return false
val logLevel = composerMarshaller.getUntyped(1) as? String ?: return false
val message = composerMarshaller.getUntyped(2) as? String ?: return false
val tag = "ComposerLogs"
when (logLevel) {
"log" -> context.log.verbose(message, tag)
"debug" -> context.log.debug(message, tag)
"info" -> context.log.info(message, tag)
"warn" -> context.log.warn(message, tag)
"error" -> context.log.error(message, tag)
}
}
else -> context.log.warn("Unknown action: $action", "Composer")
}
return true
}
private fun loadHooks() {
if (!NativeLib.initialized) {
context.log.error("ComposerHooks cannot be loaded without NativeLib")
return
}
val loaderScript = context.scriptRuntime.scripting.getScriptContent("composer/loader.js")?.let { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
it.readBytes().toString(Charsets.UTF_8)
}
} ?: run {
context.log.error("Failed to load composer loader script")
return
}
context.native.setComposerLoader("""
(() => { const callExport = require('composer_core/src/DeviceBridge')["$exportedFunctionName"]; try { $loaderScript
} catch (e) {
try {
callExport("log", "error", e.toString() + "\n" + e.stack);
} catch (t) {}
}
})();
""".trimIndent().trim())
if (config.composerConsole.get()) {
injectConsole()
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun init() { override fun init() {
if (config.globalState != true) return if (config.globalState != true) return
val importedFunctions = mutableMapOf<String, Any?>()
fun composerFunction(name: String, block: ComposerMarshaller.() -> Unit) {
importedFunctions[name] = newComposerFunction {
block(this)
true
}
}
composerFunction("getConfig") {
pushUntyped(mapOf<String, Any>(
"operaDownloadButton" to context.config.downloader.operaDownloadButton.get(),
"bypassCameraRollLimit" to config.bypassCameraRollLimit.get(),
"showFirstCreatedUsername" to config.showFirstCreatedUsername.get(),
"composerLogs" to config.composerLogs.get()
))
}
composerFunction("showToast") {
if (getSize() < 1) return@composerFunction
context.shortToast(getUntyped(0) as? String ?: return@composerFunction)
}
composerFunction("downloadLastOperaMedia") {
context.feature(MediaDownloader::class).downloadLastOperaMediaAsync(getUntyped(0) == true)
}
composerFunction("getFriendInfoByUsername") {
if (getSize() < 1) return@composerFunction
val username = getUntyped(0) as? String ?: return@composerFunction
runCatching {
pushUntyped(context.database.getFriendInfoByUsername(username)?.let {
context.gson.toJson(it)
})
}.onFailure {
pushUntyped(null)
}
}
composerFunction("log") {
if (getSize() < 2) return@composerFunction
val logLevel = getUntyped(0) as? String ?: return@composerFunction
val message = getUntyped(1) as? String ?: return@composerFunction
val tag = "ComposerLogs"
when (logLevel) {
"log" -> context.log.verbose(message, tag)
"debug" -> context.log.debug(message, tag)
"info" -> context.log.info(message, tag)
"warn" -> context.log.warn(message, tag)
"error" -> context.log.error(message, tag)
}
}
fun loadHooks() {
if (!NativeLib.initialized) {
context.log.error("ComposerHooks cannot be loaded without NativeLib")
return
}
val loaderScript = context.scriptRuntime.scripting.getScriptContent("composer/loader.js")?.let { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
it.readBytes().toString(Charsets.UTF_8)
}
} ?: run {
context.log.error("Failed to load composer loader script")
return
}
context.native.setComposerLoader("""
(() => { const _getImportsFunctionName = "$getImportsFunctionName"; $loaderScript })();
""".trimIndent().trim())
if (config.composerConsole.get()) {
injectConsole()
}
}
findClass("com.snapchat.client.composer.NativeBridge").apply { findClass("com.snapchat.client.composer.NativeBridge").apply {
hook("createViewLoaderManager", HookStage.AFTER) { loadHooks() } hook("createViewLoaderManager", HookStage.AFTER) { loadHooks() }
hook("registerNativeModuleFactory", HookStage.BEFORE) { param -> hook("registerNativeModuleFactory", HookStage.BEFORE) { param ->
val moduleFactory = param.argNullable<Any>(1) ?: return@hook val moduleFactory = param.argNullable<Any>(1) ?: return@hook
if (moduleFactory.javaClass.getMethod("getModulePath").invoke(moduleFactory)?.toString() != "DeviceBridge") return@hook if (moduleFactory.javaClass.getMethod("getModulePath").invoke(moduleFactory)?.toString() != "DeviceBridge") return@hook
Hooker.ephemeralHookObjectMethod(moduleFactory.javaClass, moduleFactory, "loadModule", HookStage.AFTER) { methodParam -> Hooker.ephemeralHookObjectMethod(moduleFactory.javaClass, moduleFactory, "loadModule", HookStage.AFTER) { methodParam ->
val functions = methodParam.getResult() as? MutableMap<String, Any> ?: return@ephemeralHookObjectMethod val result = methodParam.getResult() as? MutableMap<String, Any?> ?: return@ephemeralHookObjectMethod
functions[exportedFunctionName] = newComposerFunction { result[getImportsFunctionName] = newComposerFunction {
handleExportCall(it) pushUntyped(importedFunctions)
true
} }
} }
} }

View File

@ -20,6 +20,7 @@ dependencyResolutionManagement {
rootProject.name = "SnapEnhance" rootProject.name = "SnapEnhance"
include(":common") include(":common")
include(":core") include(":core")
include(":composer")
include(":app") include(":app")
include(":mapper") include(":mapper")
include(":native") include(":native")