feat: friend tracker (#969)

Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
Co-authored-by: Jacob Thomas <41988041+bocajthomas@users.noreply.github.com>
This commit is contained in:
auth
2024-05-21 20:48:47 +02:00
committed by GitHub
parent 43fb83ab5c
commit dadec3d278
59 changed files with 3122 additions and 1528 deletions

View File

@@ -33,6 +33,8 @@
"home_logs": "Logs",
"logger_history": "Logger History",
"logged_stories": "Logged Stories",
"friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule",
"social": "Social",
"manage_scope": "Manage Scope",
"messaging_preview": "Preview",
@@ -878,20 +880,6 @@
}
}
},
"session_events": {
"name": "Session Events",
"description": "Records session events",
"properties": {
"capture_duplex_events": {
"name": "Capture Duplex Events",
"description": "Capture presence and messaging events when a session is active"
},
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows session to run in the background"
}
}
},
"spoof": {
"name": "Spoof",
"description": "Spoof various information about you",
@@ -1039,6 +1027,20 @@
"description": "Disables the anonymization of logs"
}
}
},
"friend_tracker": {
"name": "Friend Tracker",
"description": "Records friend's activity on Snapchat",
"properties": {
"record_messaging_events": {
"name": "Record Messaging Events",
"description": "Records messaging events such as sending a opening a snap, reading a message, etc."
},
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows the tracker to run in the background. Note: This will significantly drain your battery"
}
}
}
},
"options": {

View File

@@ -1,16 +1,24 @@
package me.rhunk.snapenhance.common.action
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.PersonOutline
import androidx.compose.ui.graphics.vector.ImageVector
enum class EnumAction(
val key: String,
val icon: ImageVector,
val exitOnFinish: Boolean = false,
) {
EXPORT_CHAT_MESSAGES("export_chat_messages"),
EXPORT_MEMORIES("export_memories"),
BULK_MESSAGING_ACTION("bulk_messaging_action"),
MANAGE_FRIEND_LIST("manage_friend_list"),
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true);
EXPORT_CHAT_MESSAGES("export_chat_messages", Icons.AutoMirrored.Default.Chat),
EXPORT_MEMORIES("export_memories", Icons.Default.Image),
BULK_MESSAGING_ACTION("bulk_messaging_action", Icons.Default.DeleteOutline),
CLEAN_CACHE("clean_snapchat_cache", Icons.Default.CleaningServices, exitOnFinish = true),
MANAGE_FRIEND_LIST("manage_friend_list", Icons.Default.PersonOutline);
companion object {
const val ACTION_PARAMETER = "se_action"

View File

@@ -2,25 +2,34 @@ package me.rhunk.snapenhance.common.bridge.wrapper
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import kotlinx.coroutines.*
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
import me.rhunk.snapenhance.common.util.ktx.getIntOrNull
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import java.io.File
import java.util.UUID
class LoggedMessageEdit(
val timestamp: Long,
val messageText: String
)
class LoggedMessage(
val messageId: Long,
val timestamp: Long,
val messageData: ByteArray
val messageData: ByteArray,
)
class TrackerLog(
val id: Int,
val timestamp: Long,
val conversationId: String,
val conversationTitle: String?,
@@ -37,6 +46,7 @@ class LoggerWrapper(
private var _database: SQLiteDatabase? = null
@OptIn(ExperimentalCoroutinesApi::class)
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val gson by lazy { GsonBuilder().create() }
private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run {
@@ -50,6 +60,14 @@ class LoggerWrapper(
"message_id BIGINT",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
@@ -111,18 +129,66 @@ class LoggerWrapper(
}
override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) {
val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
val state = cursor.moveToFirst()
cursor.close()
if (state) return
val hasMessage = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())).use {
it.moveToFirst()
it.count > 0
}
if (!hasMessage) {
runBlocking {
withContext(coroutineScope.coroutineContext) {
database.insert("messages", null, ContentValues().apply {
put("added_timestamp", System.currentTimeMillis())
put("conversation_id", conversationId)
put("message_id", messageId)
put("message_data", serializedMessage)
})
}
}
}
// handle message edits
runBlocking {
withContext(coroutineScope.coroutineContext) {
database.insert("messages", null, ContentValues().apply {
put("added_timestamp", System.currentTimeMillis())
put("conversation_id", conversationId)
put("message_id", messageId)
put("message_data", serializedMessage)
})
runCatching {
val messageObject = gson.fromJson(
serializedMessage.toString(Charsets.UTF_8),
JsonObject::class.java
)
if (messageObject.getAsJsonObject("mMessageContent")
?.getAsJsonPrimitive("mContentType")?.asString != "CHAT"
) return@withContext
val metadata = messageObject.getAsJsonObject("mMetadata")
if (metadata.get("mIsEdited")?.asBoolean != true) return@withContext
val messageTextContent =
messageObject.getAsJsonObject("mMessageContent")?.getAsJsonArray("mContent")
?.map { it.asByte }?.toByteArray()?.let {
ProtoReader(it).getString(2, 1)
} ?: return@withContext
database.rawQuery(
"SELECT MAX(edit_number), message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ?",
arrayOf(conversationId, messageId.toString())
).use {
it.moveToFirst()
val editNumber = it.getInt(0)
val lastEditedMessage = it.getString(1)
if (lastEditedMessage == messageTextContent) return@withContext
database.insert("chat_edits", null, ContentValues().apply {
put("edit_number", editNumber + 1)
put("added_timestamp", System.currentTimeMillis())
put("conversation_id", conversationId)
put("message_id", messageId)
put("message_text", messageTextContent)
})
}
}.onFailure {
AbstractLogger.directDebug("Failed to handle message edit: ${it.message}")
}
}
}
}
@@ -132,9 +198,11 @@ class LoggerWrapper(
maxAge?.let {
val maxTime = System.currentTimeMillis() - it
database.execSQL("DELETE FROM messages WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
database.execSQL("DELETE FROM chat_edits WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
database.execSQL("DELETE FROM stories WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
} ?: run {
database.execSQL("DELETE FROM messages")
database.execSQL("DELETE FROM chat_edits")
database.execSQL("DELETE FROM stories")
}
}
@@ -157,6 +225,7 @@ class LoggerWrapper(
override fun deleteMessage(conversationId: String, messageId: Long) {
coroutineScope.launch {
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
database.execSQL("DELETE FROM chat_edits WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
}
}
@@ -207,6 +276,12 @@ class LoggerWrapper(
}
}
fun deleteTrackerLog(id: Int) {
coroutineScope.launch {
database.execSQL("DELETE FROM tracker_events WHERE id = ?", arrayOf(id.toString()))
}
}
fun getLogs(
lastTimestamp: Long,
filter: ((TrackerLog) -> Boolean)? = null
@@ -215,6 +290,7 @@ class LoggerWrapper(
val logs = mutableListOf<TrackerLog>()
while (it.moveToNext() && logs.size < 50) {
val log = TrackerLog(
id = it.getIntOrNull("id") ?: continue,
timestamp = it.getLongOrNull("timestamp") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue,
conversationTitle = it.getStringOrNull("conversation_title"),
@@ -278,6 +354,22 @@ class LoggerWrapper(
}
}
fun getMessageEdits(conversationId: String, messageId: Long): List<LoggedMessageEdit> {
val edits = mutableListOf<LoggedMessageEdit>()
database.rawQuery(
"SELECT added_timestamp, message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ?",
arrayOf(conversationId, messageId.toString())
).use {
while (it.moveToNext()) {
edits.add(LoggedMessageEdit(
timestamp = it.getLongOrNull("added_timestamp") ?: continue,
messageText = it.getStringOrNull("message_text") ?: continue
))
}
}
return edits
}
fun fetchMessages(
conversationId: String,
fromTimestamp: Long,

View File

@@ -1,5 +1,6 @@
package me.rhunk.snapenhance.common.config
import androidx.compose.ui.graphics.vector.ImageVector
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import kotlin.reflect.KProperty
@@ -35,7 +36,7 @@ class ConfigParams(
private var _flags: Int? = null,
private var _notices: Int? = null,
var icon: String? = null,
var icon: ImageVector? = null,
var disabledKey: String? = null,
var customTranslationPath: String? = null,
var customOptionTranslationPath: String? = null,

View File

@@ -1,14 +1,12 @@
package me.rhunk.snapenhance.common.config.impl
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Memory
import me.rhunk.snapenhance.common.config.ConfigContainer
import me.rhunk.snapenhance.common.config.FeatureNotice
class Experimental : ConfigContainer() {
class SessionEventsConfig : ConfigContainer(hasGlobalState = true) {
val captureDuplexEvents = boolean("capture_duplex_events", true)
val allowRunningInBackground = boolean("allow_running_in_background", true)
}
class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) {
val showFirstCreatedUsername = boolean("show_first_created_username")
val bypassCameraRollLimit = boolean("bypass_camera_roll_limit")
@@ -34,9 +32,8 @@ class Experimental : ConfigContainer() {
val lockOnResume = boolean("lock_on_resume", defaultValue = true)
}
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() }
val sessionEvents = container("session_events", SessionEventsConfig()) { requireRestart(); nativeHooks() }
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() }
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }

View File

@@ -0,0 +1,8 @@
package me.rhunk.snapenhance.common.config.impl
import me.rhunk.snapenhance.common.config.ConfigContainer
class FriendTrackerConfig: ConfigContainer(hasGlobalState = true) {
val recordMessagingEvents = boolean("record_messaging_events", false)
val allowRunningInBackground = boolean("allow_running_in_background", false)
}

View File

@@ -1,17 +1,22 @@
package me.rhunk.snapenhance.common.config.impl
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Rule
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.*
import me.rhunk.snapenhance.common.config.ConfigContainer
import me.rhunk.snapenhance.common.config.FeatureNotice
class RootConfig : ConfigContainer() {
val downloader = container("downloader", DownloaderConfig()) { icon = "Download"}
val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"}
val messaging = container("messaging", MessagingTweaks()) { icon = "Send" }
val global = container("global", Global()) { icon = "MiscellaneousServices" }
val rules = container("rules", Rules()) { icon = "Rule" }
val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() }
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" }
val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices(
val downloader = container("downloader", DownloaderConfig()) { icon = Icons.Default.Download }
val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = Icons.Default.RemoveRedEye }
val messaging = container("messaging", MessagingTweaks()) { icon = Icons.AutoMirrored.Default.Send }
val global = container("global", Global()) { icon = Icons.Default.MiscellaneousServices }
val rules = container("rules", Rules()) { icon = Icons.AutoMirrored.Default.Rule }
val camera = container("camera", Camera()) { icon = Icons.Default.Camera; requireRestart() }
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = Icons.Default.Alarm }
val experimental = container("experimental", Experimental()) { icon = Icons.Default.Science; addNotices(
FeatureNotice.UNSTABLE) }
val scripting = container("scripting", Scripting()) { icon = "DataObject" }
val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject }
val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch; nativeHooks() }
}

View File

@@ -35,6 +35,7 @@ enum class SessionEventType(
MESSAGE_DELETED("message_deleted"),
MESSAGE_SAVED("message_saved"),
MESSAGE_UNSAVED("message_unsaved"),
MESSAGE_EDITED("message_edited"),
MESSAGE_REACTION_ADD("message_reaction_add"),
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
SNAP_OPENED("snap_opened"),
@@ -44,70 +45,6 @@ enum class SessionEventType(
SNAP_SCREEN_RECORD("snap_screen_record"),
}
object TrackerFlags {
const val TRACK = 1
const val LOG = 2
const val NOTIFY = 4
const val APP_IS_ACTIVE = 8
const val APP_IS_INACTIVE = 16
const val IS_IN_CONVERSATION = 32
}
@Parcelize
class TrackerEventsResult(
private val rules: Map<TrackerRule, List<TrackerRuleEvent>>
): Parcelable {
fun hasFlags(vararg flags: Int): Boolean {
return rules.any { (_, ruleEvents) ->
ruleEvents.any { flags.all { flag -> it.flags and flag != 0 } }
}
}
fun canTrackOn(conversationId: String?, userId: String?): Boolean {
return rules.any t@{ (rule, ruleEvents) ->
ruleEvents.any { event ->
if (event.flags and TrackerFlags.TRACK == 0) {
return@any false
}
// global rule
if (rule.conversationId == null && rule.userId == null) {
return@any true
}
// user rule
if (rule.conversationId == null && rule.userId == userId) {
return@any true
}
// conversation rule
if (rule.conversationId == conversationId && rule.userId == null) {
return@any true
}
// conversation and user rule
return@any rule.conversationId == conversationId && rule.userId == userId
}
}
}
}
@Parcelize
data class TrackerRule(
val id: Int,
val flags: Int,
val conversationId: String?,
val userId: String?
): Parcelable
@Parcelize
data class TrackerRuleEvent(
val id: Int,
val flags: Int,
val eventType: String,
): Parcelable
enum class TrackerEventType(
val key: String
) {
@@ -126,6 +63,7 @@ enum class TrackerEventType(
MESSAGE_DELETED("message_deleted"),
MESSAGE_SAVED("message_saved"),
MESSAGE_UNSAVED("message_unsaved"),
MESSAGE_EDITED("message_edited"),
MESSAGE_REACTION_ADD("message_reaction_add"),
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
SNAP_OPENED("snap_opened"),
@@ -134,3 +72,104 @@ enum class TrackerEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
}
@Parcelize
class TrackerEventsResult(
val rules: Map<ScopedTrackerRule, List<TrackerRuleEvent>>,
): Parcelable {
fun getActions(): Map<TrackerRuleAction, TrackerRuleActionParams> {
return rules.flatMap {
it.value
}.fold(mutableMapOf()) { acc, ruleEvent ->
ruleEvent.actions.forEach { action ->
acc[action] = acc[action]?.merge(ruleEvent.params) ?: ruleEvent.params
}
acc
}
}
fun canTrackOn(conversationId: String?, userId: String?): Boolean {
return rules.any { (scopedRule, events) ->
if (!events.any { it.enabled }) return@any false
val scopes = scopedRule.scopes
when (scopes[userId]) {
TrackerScopeType.WHITELIST -> return@any true
TrackerScopeType.BLACKLIST -> return@any false
else -> {}
}
when (scopes[conversationId]) {
TrackerScopeType.WHITELIST -> return@any true
TrackerScopeType.BLACKLIST -> return@any false
else -> {}
}
return@any scopes.isEmpty() || scopes.any { it.value == TrackerScopeType.BLACKLIST }
}
}
}
enum class TrackerRuleAction(
val key: String
) {
LOG("log"),
IN_APP_NOTIFICATION("in_app_notification"),
PUSH_NOTIFICATION("push_notification"),
CUSTOM("custom");
companion object {
fun fromString(value: String): TrackerRuleAction? {
return entries.find { it.key == value }
}
}
}
@Parcelize
data class TrackerRuleActionParams(
var onlyInsideConversation: Boolean = false,
var onlyOutsideConversation: Boolean = false,
var onlyWhenAppActive: Boolean = false,
var onlyWhenAppInactive: Boolean = false,
var noPushNotificationWhenAppActive: Boolean = false,
): Parcelable {
fun merge(other: TrackerRuleActionParams): TrackerRuleActionParams {
return TrackerRuleActionParams(
onlyInsideConversation = onlyInsideConversation || other.onlyInsideConversation,
onlyOutsideConversation = onlyOutsideConversation || other.onlyOutsideConversation,
onlyWhenAppActive = onlyWhenAppActive || other.onlyWhenAppActive,
onlyWhenAppInactive = onlyWhenAppInactive || other.onlyWhenAppInactive,
noPushNotificationWhenAppActive = noPushNotificationWhenAppActive || other.noPushNotificationWhenAppActive,
)
}
}
@Parcelize
data class TrackerRule(
val id: Int,
val enabled: Boolean,
val name: String,
): Parcelable
@Parcelize
data class ScopedTrackerRule(
val rule: TrackerRule,
val scopes: Map<String, TrackerScopeType>
): Parcelable
enum class TrackerScopeType(
val key: String
) {
WHITELIST("whitelist"),
BLACKLIST("blacklist");
}
@Parcelize
data class TrackerRuleEvent(
val id: Int,
val enabled: Boolean,
val eventType: String,
val params: TrackerRuleActionParams,
val actions: List<TrackerRuleAction>
): Parcelable

View File

@@ -4,8 +4,8 @@ import android.os.Handler
import android.widget.Toast
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
import me.rhunk.snapenhance.common.scripting.bindings.BindingsContext
import me.rhunk.snapenhance.common.scripting.impl.Networking
import me.rhunk.snapenhance.common.scripting.impl.JavaInterfaces
import me.rhunk.snapenhance.common.scripting.impl.Networking
import me.rhunk.snapenhance.common.scripting.ktx.contextScope
import me.rhunk.snapenhance.common.scripting.ktx.putFunction
import me.rhunk.snapenhance.common.scripting.ktx.scriptable
@@ -18,13 +18,14 @@ import org.mozilla.javascript.NativeJavaObject
import org.mozilla.javascript.ScriptableObject
import org.mozilla.javascript.Undefined
import org.mozilla.javascript.Wrapper
import java.io.Reader
import java.lang.reflect.Modifier
import kotlin.reflect.KClass
class JSModule(
val scriptRuntime: ScriptRuntime,
private val scriptRuntime: ScriptRuntime,
val moduleInfo: ModuleInfo,
val content: String,
private val reader: Reader,
) {
private val moduleBindings = mutableMapOf<String, AbstractBinding>()
private lateinit var moduleObject: ScriptableObject
@@ -53,6 +54,18 @@ class JSModule(
})
})
scriptRuntime.logger.apply {
moduleObject.putConst("console", moduleObject, scriptableObject {
putFunction("log") { info(argsToString(it)) }
putFunction("warn") { warn(argsToString(it)) }
putFunction("error") { error(argsToString(it)) }
putFunction("debug") { debug(argsToString(it)) }
putFunction("info") { info(argsToString(it)) }
putFunction("trace") { verbose(argsToString(it)) }
putFunction("verbose") { verbose(argsToString(it)) }
})
}
registerBindings(
JavaInterfaces(),
InterfaceManager(),
@@ -186,7 +199,7 @@ class JSModule(
}
contextScope(shouldOptimize = true) {
evaluateString(moduleObject, content, moduleInfo.name, 1, null)
evaluateReader(moduleObject, reader, moduleInfo.name, 1, null)
}
}
@@ -233,7 +246,10 @@ class JSModule(
private fun argsToString(args: Array<out Any?>?): String {
return args?.joinToString(" ") {
when (it) {
is Wrapper -> it.unwrap().toString()
is Wrapper -> it.unwrap().let { value ->
if (value is Throwable) value.message + "\n" + value.stackTraceToString()
else value.toString()
}
else -> it.toString()
}
} ?: "null"

View File

@@ -3,11 +3,11 @@ package me.rhunk.snapenhance.common.scripting
import android.content.Context
import android.os.ParcelFileDescriptor
import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.common.BuildConfig
import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import org.mozilla.javascript.ScriptableObject
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStream
open class ScriptRuntime(
@@ -35,7 +35,7 @@ open class ScriptRuntime(
return modules.values.find { it.moduleInfo.name == name }
}
private fun readModuleInfo(reader: BufferedReader): ModuleInfo {
fun readModuleInfo(reader: BufferedReader): ModuleInfo {
val header = reader.readLine()
if (!header.startsWith("// ==SE_module==")) {
throw Exception("Invalid module header")
@@ -74,6 +74,10 @@ open class ScriptRuntime(
return readModuleInfo(inputStream.bufferedReader())
}
fun removeModule(scriptPath: String) {
modules.remove(scriptPath)
}
fun unload(scriptPath: String) {
val module = modules[scriptPath] ?: return
logger.info("Unloading module $scriptPath")
@@ -81,27 +85,30 @@ open class ScriptRuntime(
modules.remove(scriptPath)
}
fun load(scriptPath: String, pfd: ParcelFileDescriptor) {
load(scriptPath, ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
it.readBytes().toString(Charsets.UTF_8)
})
fun load(scriptPath: String, pfd: ParcelFileDescriptor): JSModule {
return ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
load(scriptPath, it)
}
}
fun load(scriptPath: String, content: String): JSModule? {
fun load(scriptPath: String, content: InputStream): JSModule {
logger.info("Loading module $scriptPath")
return runCatching {
JSModule(
scriptRuntime = this,
moduleInfo = readModuleInfo(ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)).bufferedReader()),
content = content,
).apply {
load {
buildModuleObject(this, this@apply)
}
modules[scriptPath] = this
val bufferedReader = content.bufferedReader()
val moduleInfo = readModuleInfo(bufferedReader)
if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) {
throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})")
}
return JSModule(
scriptRuntime = this,
moduleInfo = moduleInfo,
reader = bufferedReader,
).apply {
load {
buildModuleObject(this, this@apply)
}
}.onFailure {
logger.error("Failed to load module $scriptPath", it)
}.getOrNull()
modules[scriptPath] = this
}
}
}

View File

@@ -0,0 +1,103 @@
package me.rhunk.snapenhance.common.ui
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.CopyOnWriteArrayList
class AsyncUpdateDispatcher(
val updateOnFirstComposition: Boolean = true
) {
private val callbacks = CopyOnWriteArrayList<suspend () -> Unit>()
suspend fun dispatch() {
callbacks.forEach { it() }
}
fun addCallback(callback: suspend () -> Unit) {
callbacks.add(callback)
}
fun removeCallback(callback: suspend () -> Unit) {
callbacks.remove(callback)
}
}
@Composable
fun rememberAsyncUpdateDispatcher(): AsyncUpdateDispatcher {
return remember { AsyncUpdateDispatcher() }
}
@Composable
private fun <T> rememberCommonState(
initialState: () -> T,
setter: suspend T.() -> Unit,
updateDispatcher: AsyncUpdateDispatcher? = null,
keys: Array<*> = emptyArray<Any>(),
): T {
return remember { initialState() }.apply {
var asyncSetCallback by remember { mutableStateOf(suspend {}) }
LaunchedEffect(Unit) {
asyncSetCallback = { setter(this@apply) }
updateDispatcher?.addCallback(asyncSetCallback)
}
DisposableEffect(Unit) {
onDispose { updateDispatcher?.removeCallback(asyncSetCallback) }
}
if (updateDispatcher?.updateOnFirstComposition != false) {
LaunchedEffect(*keys) {
setter(this@apply)
}
}
}
}
@Composable
fun <T> rememberAsyncMutableState(
defaultValue: T,
updateDispatcher: AsyncUpdateDispatcher? = null,
keys: Array<*> = emptyArray<Any>(),
getter: () -> T,
): MutableState<T> {
return rememberCommonState(
initialState = { mutableStateOf(defaultValue) },
setter = {
withContext(Dispatchers.Main) {
value = withContext(Dispatchers.IO) {
getter()
}
}
},
updateDispatcher = updateDispatcher,
keys = keys,
)
}
@Composable
fun <T> rememberAsyncMutableStateList(
defaultValue: List<T>,
updateDispatcher: AsyncUpdateDispatcher? = null,
keys: Array<*> = emptyArray<Any>(),
getter: () -> List<T>,
): SnapshotStateList<T> {
return rememberCommonState(
initialState = { mutableStateListOf<T>().apply {
addAll(defaultValue)
}},
setter = {
withContext(Dispatchers.Main) {
clear()
addAll(withContext(Dispatchers.IO) {
getter()
})
}
},
updateDispatcher = updateDispatcher,
keys = keys,
)
}

View File

@@ -16,7 +16,7 @@ object BitmojiSelfie {
return when (type) {
BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1"
BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle"
BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=1"
BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=2"
}
}
}