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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 3122 additions and 1528 deletions

View File

@ -65,7 +65,7 @@
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:exported="true" /> android:exported="true" />
<receiver android:name=".messaging.StreaksReminder" /> <receiver android:name=".StreaksReminder" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View File

@ -2,6 +2,8 @@ package me.rhunk.snapenhance
import android.util.Log import android.util.Log
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.FileType import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.logger.AbstractLogger
import me.rhunk.snapenhance.common.logger.LogChannel import me.rhunk.snapenhance.common.logger.LogChannel
@ -70,33 +72,39 @@ class LogReader(
} }
fun incrementLineCount() { fun incrementLineCount() {
randomAccessFile.seek(randomAccessFile.length()) synchronized(randomAccessFile) {
startLineIndexes.add(randomAccessFile.filePointer + 1) randomAccessFile.seek(randomAccessFile.length())
lineCount++ startLineIndexes.add(randomAccessFile.filePointer + 1)
lineCount++
}
} }
private fun queryLineCount(): Int { private fun queryLineCount(): Int {
randomAccessFile.seek(0) synchronized(randomAccessFile) {
var lineCount = 0 randomAccessFile.seek(0)
var lastPointer: Long var lineCount = 0
var line: String? var lastPointer: Long
var line: String?
while (randomAccessFile.also { while (randomAccessFile.also {
lastPointer = it.filePointer lastPointer = it.filePointer
}.readLine().also { line = it } != null) { }.readLine().also { line = it } != null) {
if (line?.startsWith('|') == true) { if (line?.startsWith('|') == true) {
lineCount++ lineCount++
startLineIndexes.add(lastPointer + 1) startLineIndexes.add(lastPointer + 1)
}
} }
}
return lineCount return lineCount
}
} }
private fun getLine(index: Int): String? { private fun getLine(index: Int): String? {
if (index <= 0 || index > lineCount) return null if (index <= 0 || index > lineCount) return null
randomAccessFile.seek(startLineIndexes[index]) synchronized(randomAccessFile) {
return readLogLine()?.toString() randomAccessFile.seek(startLineIndexes[index])
return readLogLine()?.toString()
}
} }
fun getLogLine(index: Int): LogLine? { fun getLogLine(index: Int): LogLine? {
@ -109,7 +117,6 @@ class LogManager(
private val remoteSideContext: RemoteSideContext private val remoteSideContext: RemoteSideContext
): AbstractLogger(LogChannel.MANAGER) { ): AbstractLogger(LogChannel.MANAGER) {
companion object { companion object {
private const val TAG = "SnapEnhanceManager"
private val LOG_LIFETIME = 24.hours private val LOG_LIFETIME = 24.hours
} }
@ -118,13 +125,13 @@ class LogManager(
var lineAddListener = { _: LogLine -> } var lineAddListener = { _: LogLine -> }
private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs")
private var logFile: File private var logFile: File? = null
private val uuidRegex by lazy { Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOption.MULTILINE) } private val uuidRegex by lazy { Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOption.MULTILINE) }
private val contentUriRegex by lazy { Regex("content://[a-zA-Z0-9_\\-./]+") } private val contentUriRegex by lazy { Regex("content://[a-zA-Z0-9_\\-./]+") }
private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") } private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") }
init { fun init() {
if (!logFolder.exists()) { if (!logFolder.exists()) {
logFolder.mkdirs() logFolder.mkdirs()
} }
@ -153,7 +160,9 @@ class LogManager(
tag = tag, tag = tag,
message = anonymizedMessage message = anonymizedMessage
) )
logFile.appendText("|$line\n", Charsets.UTF_8) remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
logFile?.appendText("|$line\n", Charsets.UTF_8)
}
lineAddListener(line) lineAddListener(line)
Log.println(logLevel.priority, tag, anonymizedMessage) Log.println(logLevel.priority, tag, anonymizedMessage)
}.onFailure { }.onFailure {
@ -172,8 +181,8 @@ class LogManager(
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also { logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
it.createNewFile() it.createNewFile()
remoteSideContext.sharedPreferences.edit().putString("log_file", it.absolutePath).putLong("last_created", currentTime).apply()
} }
remoteSideContext.sharedPreferences.edit().putString("log_file", logFile.absolutePath).putLong("last_created", currentTime).apply()
} }
fun clearLogs() { fun clearLogs() {
@ -201,7 +210,7 @@ class LogManager(
zipOutputStream.close() zipOutputStream.close()
} }
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also { fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also {
lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) } lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) }
} }

View File

@ -7,8 +7,10 @@ import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor
class RemoteAccountStorage( class RemoteAccountStorage(
private val context: RemoteSideContext private val context: RemoteSideContext
): AccountStorage.Stub() { ): AccountStorage.Stub() {
private val accountFolder = context.androidContext.filesDir.resolve("accounts").also { private val accountFolder by lazy {
if (!it.exists()) it.mkdirs() context.androidContext.filesDir.resolve("accounts").also {
if (!it.exists()) it.mkdirs()
}
} }
override fun getAccounts(): Map<String, String> { override fun getAccounts(): Map<String, String> {

View File

@ -19,6 +19,8 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.bridge.BridgeService import me.rhunk.snapenhance.bridge.BridgeService
import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.BuildConfig
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
@ -27,9 +29,8 @@ import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.common.config.ModConfig import me.rhunk.snapenhance.common.config.ModConfig
import me.rhunk.snapenhance.e2ee.E2EEImplementation import me.rhunk.snapenhance.e2ee.E2EEImplementation
import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder
import me.rhunk.snapenhance.scripting.RemoteScriptManager import me.rhunk.snapenhance.scripting.RemoteScriptManager
import me.rhunk.snapenhance.storage.AppDatabase
import me.rhunk.snapenhance.task.TaskManager import me.rhunk.snapenhance.task.TaskManager
import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
@ -63,7 +64,7 @@ class RemoteSideContext(
val translation = LocaleWrapper() val translation = LocaleWrapper()
val mappings = MappingsWrapper() val mappings = MappingsWrapper()
val taskManager = TaskManager(this) val taskManager = TaskManager(this)
val modDatabase = ModDatabase(this) val database = AppDatabase(this)
val streaksReminder = StreaksReminder(this) val streaksReminder = StreaksReminder(this)
val log = LogManager(this) val log = LogManager(this)
val scriptManager = RemoteScriptManager(this) val scriptManager = RemoteScriptManager(this)
@ -94,27 +95,32 @@ class RemoteSideContext(
val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() } val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() }
fun reload() { fun reload() {
log.verbose("Loading RemoteSideContext")
runCatching { runCatching {
config.loadFromContext(androidContext) runBlocking(Dispatchers.IO) {
translation.apply { log.init()
userLocale = config.locale log.verbose("Loading RemoteSideContext")
loadFromContext(androidContext) config.loadFromContext(androidContext)
} launch {
mappings.apply { mappings.apply {
loadFromContext(androidContext) loadFromContext(androidContext)
init(androidContext) init(androidContext)
} }
taskManager.init() }
modDatabase.init() translation.apply {
streaksReminder.init() userLocale = config.locale
scriptManager.init() loadFromContext(androidContext)
messageLogger.init() }
tracker.init() database.init()
config.root.messaging.messageLogger.takeIf { streaksReminder.init()
it.globalState == true scriptManager.init()
}?.getAutoPurgeTime()?.let { launch {
messageLogger.purgeAll(it) taskManager.init()
config.root.messaging.messageLogger.takeIf {
it.globalState == true
}?.getAutoPurgeTime()?.let {
messageLogger.purgeAll(it)
}
}
} }
}.onFailure { }.onFailure {
log.error("Failed to load RemoteSideContext", it) log.error("Failed to load RemoteSideContext", it)

View File

@ -1,29 +1,29 @@
package me.rhunk.snapenhance package me.rhunk.snapenhance
import me.rhunk.snapenhance.bridge.logger.TrackerInterface import me.rhunk.snapenhance.bridge.logger.TrackerInterface
import me.rhunk.snapenhance.common.data.ScopedTrackerRule
import me.rhunk.snapenhance.common.data.TrackerEventsResult import me.rhunk.snapenhance.common.data.TrackerEventsResult
import me.rhunk.snapenhance.common.data.TrackerRule import me.rhunk.snapenhance.common.data.TrackerRule
import me.rhunk.snapenhance.common.data.TrackerRuleEvent import me.rhunk.snapenhance.common.data.TrackerRuleEvent
import me.rhunk.snapenhance.common.util.toSerialized import me.rhunk.snapenhance.common.util.toSerialized
import me.rhunk.snapenhance.storage.getRuleTrackerScopes
import me.rhunk.snapenhance.storage.getTrackerEvents
class RemoteTracker( class RemoteTracker(
private val context: RemoteSideContext private val context: RemoteSideContext
): TrackerInterface.Stub() { ): TrackerInterface.Stub() {
fun init() { fun init() {}
/*TrackerEventType.entries.forEach { eventType ->
val ruleId = context.modDatabase.addTrackerRule(TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, null, null)
context.modDatabase.addTrackerRuleEvent(ruleId, TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, eventType.key)
}*/
}
override fun getTrackedEvents(eventType: String): String? { override fun getTrackedEvents(eventType: String): String? {
val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>() val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>()
context.modDatabase.getTrackerEvents(eventType).forEach { (event, rule) -> context.database.getTrackerEvents(eventType).forEach { (event, rule) ->
events.getOrPut(rule) { mutableListOf() }.add(event) events.getOrPut(rule) { mutableListOf() }.add(event)
} }
return TrackerEventsResult(events).toSerialized() return TrackerEventsResult(events.mapKeys {
ScopedTrackerRule(it.key, context.database.getRuleTrackerScopes(it.key.id))
}).toSerialized()
} }
} }

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.messaging package me.rhunk.snapenhance
import android.app.AlarmManager import android.app.AlarmManager
import android.app.NotificationChannel import android.app.NotificationChannel
@ -10,11 +10,10 @@ import android.content.Intent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.ForceStartActivity import me.rhunk.snapenhance.bridge.ForceStartActivity
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.storage.getFriendStreaks
import me.rhunk.snapenhance.storage.getFriends
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -56,8 +55,8 @@ class StreaksReminder(
PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_IMMUTABLE)
) )
val notifyFriendList = remoteSideContext.modDatabase.getFriends() val notifyFriendList = remoteSideContext.database.getFriends()
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) } .associateBy { remoteSideContext.database.getFriendStreaks(it.userId) }
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) } .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
val notificationManager = getNotificationManager(ctx) val notificationManager = getNotificationManager(ctx)

View File

@ -18,6 +18,7 @@ import me.rhunk.snapenhance.common.logger.LogLevel
import me.rhunk.snapenhance.common.util.toParcelable import me.rhunk.snapenhance.common.util.toParcelable
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.download.FFMpegProcessor import me.rhunk.snapenhance.download.FFMpegProcessor
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.task.Task import me.rhunk.snapenhance.task.Task
import me.rhunk.snapenhance.task.TaskType import me.rhunk.snapenhance.task.TaskType
import java.io.File import java.io.File
@ -47,7 +48,7 @@ class BridgeService : Service() {
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) { fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
runCatching { runCatching {
val modDatabase = remoteSideContext.modDatabase val modDatabase = remoteSideContext.database
val syncedObject = when (scope) { val syncedObject = when (scope) {
SocialScope.FRIEND -> { SocialScope.FRIEND -> {
if (updateOnly && modDatabase.getFriendInfo(id) == null) return if (updateOnly && modDatabase.getFriendInfo(id) == null) return
@ -194,24 +195,24 @@ class BridgeService : Service() {
} }
override fun getRules(uuid: String): List<String> { override fun getRules(uuid: String): List<String> {
return remoteSideContext.modDatabase.getRules(uuid).map { it.key } return remoteSideContext.database.getRules(uuid).map { it.key }
} }
override fun getRuleIds(type: String): MutableList<String> { override fun getRuleIds(type: String): MutableList<String> {
return remoteSideContext.modDatabase.getRuleIds(type) return remoteSideContext.database.getRuleIds(type)
} }
override fun setRule(uuid: String, rule: String, state: Boolean) { override fun setRule(uuid: String, rule: String, state: Boolean) {
remoteSideContext.modDatabase.setRule(uuid, rule, state) remoteSideContext.database.setRule(uuid, rule, state)
} }
override fun sync(callback: SyncCallback) { override fun sync(callback: SyncCallback) {
syncCallback = callback syncCallback = callback
measureTimeMillis { measureTimeMillis {
remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId -> remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
triggerScopeSync(SocialScope.FRIEND, friendId, true) triggerScopeSync(SocialScope.FRIEND, friendId, true)
} }
remoteSideContext.modDatabase.getGroups().map { it.conversationId }.forEach { groupId -> remoteSideContext.database.getGroups().map { it.conversationId }.forEach { groupId ->
triggerScopeSync(SocialScope.GROUP, groupId, true) triggerScopeSync(SocialScope.GROUP, groupId, true)
} }
}.also { }.also {
@ -229,7 +230,7 @@ class BridgeService : Service() {
friends: List<String> friends: List<String>
) { ) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.modDatabase.receiveMessagingDataCallback( remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }, friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) } groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
) )

View File

@ -1,6 +1,5 @@
package me.rhunk.snapenhance.download package me.rhunk.snapenhance.download
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory

View File

@ -1,405 +0,0 @@
package me.rhunk.snapenhance.messaging
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.data.*
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.util.concurrent.Executors
import kotlin.coroutines.suspendCoroutine
class ModDatabase(
private val context: RemoteSideContext,
) {
private val executor = Executors.newSingleThreadExecutor()
private lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
fun executeAsync(block: () -> Unit) {
executor.execute {
runCatching {
block()
}.onFailure {
context.log.error("Failed to execute async block", it)
}
}
}
fun init() {
database = context.androidContext.openOrCreateDatabase("main.db", 0, null)
SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf(
"friends" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"userId CHAR(36) UNIQUE",
"dmConversationId VARCHAR(36)",
"displayName VARCHAR",
"mutableUsername VARCHAR",
"bitmojiId VARCHAR",
"selfieId VARCHAR"
),
"groups" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"conversationId CHAR(36) UNIQUE",
"name VARCHAR",
"participantsCount INTEGER"
),
"rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"type VARCHAR",
"targetUuid VARCHAR"
),
"streaks" to listOf(
"id VARCHAR PRIMARY KEY",
"notify BOOLEAN",
"expirationTimestamp BIGINT",
"length INTEGER"
),
"scripts" to listOf(
"name VARCHAR PRIMARY KEY",
"version VARCHAR NOT NULL",
"displayName VARCHAR",
"description VARCHAR",
"author VARCHAR NOT NULL",
"enabled BOOLEAN"
),
"tracker_rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"flags INTEGER",
"conversation_id CHAR(36)", // nullable
"user_id CHAR(36)", // nullable
),
"tracker_rules_events" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"flags INTEGER",
"rule_id INTEGER",
"event_type VARCHAR",
)
))
}
fun getGroups(): List<MessagingGroupInfo> {
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
val groups = mutableListOf<MessagingGroupInfo>()
while (cursor.moveToNext()) {
groups.add(MessagingGroupInfo.fromCursor(cursor))
}
groups
}
}
fun getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor ->
val friends = mutableListOf<MessagingFriendInfo>()
while (cursor.moveToNext()) {
runCatching {
friends.add(MessagingFriendInfo.fromCursor(cursor))
}.onFailure {
context.log.error("Failed to parse friend", it)
}
}
friends
}
}
fun syncGroupInfo(conversationInfo: MessagingGroupInfo) {
executeAsync {
try {
database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf(
conversationInfo.conversationId,
conversationInfo.name,
conversationInfo.participantsCount
))
} catch (e: Exception) {
throw e
}
}
}
fun syncFriend(friend: MessagingFriendInfo) {
executeAsync {
try {
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
)
)
//sync streaks
friend.streaks?.takeIf { it.length > 0 }?.let {
val streaks = getFriendStreaks(friend.userId)
database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
friend.userId,
streaks?.notify ?: true,
it.expirationTimestamp,
it.length
))
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
} catch (e: Exception) {
throw e
}
}
}
fun getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
targetUuid
)).use { cursor ->
val rules = mutableListOf<MessagingRuleType>()
while (cursor.moveToNext()) {
runCatching {
rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching)
}.onFailure {
context.log.error("Failed to parse rule", it)
}
}
rules
}
}
fun setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
if (enabled) {
database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf(
targetUuid,
type
))
} else {
database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf(
targetUuid,
type
))
}
}
}
fun getFriendInfo(userId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun findFriend(conversationId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun deleteFriend(userId: String) {
executeAsync {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId))
}
}
fun deleteGroup(conversationId: String) {
executeAsync {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId))
}
}
fun getGroupInfo(conversationId: String): MessagingGroupInfo? {
return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingGroupInfo.fromCursor(cursor)
}
}
fun getFriendStreaks(userId: String): FriendStreaks? {
return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
FriendStreaks(
notify = cursor.getInteger("notify") == 1,
expirationTimestamp = cursor.getLongOrNull("expirationTimestamp") ?: 0L,
length = cursor.getInteger("length")
)
}
}
fun setFriendStreaksNotify(userId: String, notify: Boolean) {
executeAsync {
database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf(
if (notify) 1 else 0,
userId
))
}
}
fun getRuleIds(type: String): MutableList<String> {
return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
val ruleIds = mutableListOf<String>()
while (cursor.moveToNext()) {
ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
}
ruleIds
}
}
fun getScripts(): List<ModuleInfo> {
return database.rawQuery("SELECT * FROM scripts", null).use { cursor ->
val scripts = mutableListOf<ModuleInfo>()
while (cursor.moveToNext()) {
scripts.add(
ModuleInfo(
name = cursor.getStringOrNull("name")!!,
version = cursor.getStringOrNull("version")!!,
displayName = cursor.getStringOrNull("displayName"),
description = cursor.getStringOrNull("description"),
author = cursor.getStringOrNull("author"),
grantedPermissions = emptyList()
)
)
}
scripts
}
}
fun setScriptEnabled(name: String, enabled: Boolean) {
executeAsync {
database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf(
if (enabled) 1 else 0,
name
))
}
}
fun isScriptEnabled(name: String): Boolean {
return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor ->
if (!cursor.moveToFirst()) return@use false
cursor.getInteger("enabled") == 1
}
}
fun syncScripts(availableScripts: List<ModuleInfo>) {
executeAsync {
val enabledScripts = getScripts()
val enabledScriptPaths = enabledScripts.map { it.name }
val availableScriptPaths = availableScripts.map { it.name }
enabledScripts.forEach { script ->
if (!availableScriptPaths.contains(script.name)) {
database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name))
}
}
availableScripts.forEach { script ->
if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) {
database.execSQL(
"INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(
script.name,
script.version,
script.displayName,
script.description,
script.author,
0
)
)
}
}
}
}
fun addTrackerRule(flags: Int, conversationId: String?, userId: String?): Int {
return runBlocking {
suspendCoroutine { continuation ->
executeAsync {
val id = database.insert("tracker_rules", null, ContentValues().apply {
put("flags", flags)
put("conversation_id", conversationId)
put("user_id", userId)
})
continuation.resumeWith(Result.success(id.toInt()))
}
}
}
}
fun addTrackerRuleEvent(ruleId: Int, flags: Int, eventType: String) {
executeAsync {
database.execSQL("INSERT INTO tracker_rules_events (flags, rule_id, event_type) VALUES (?, ?, ?)", arrayOf(
flags,
ruleId,
eventType
))
}
}
fun getTrackerRules(conversationId: String?, userId: String?): List<TrackerRule> {
val rules = mutableListOf<TrackerRule>()
database.rawQuery("SELECT * FROM tracker_rules WHERE (conversation_id = ? OR conversation_id IS NULL) AND (user_id = ? OR user_id IS NULL)", arrayOf(conversationId, userId).filterNotNull().toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
rules.add(
TrackerRule(
id = cursor.getInteger("id"),
flags = cursor.getInteger("flags"),
conversationId = cursor.getStringOrNull("conversation_id"),
userId = cursor.getStringOrNull("user_id")
)
)
}
}
return rules
}
fun getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> {
val events = mutableListOf<TrackerRuleEvent>()
database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor ->
while (cursor.moveToNext()) {
events.add(
TrackerRuleEvent(
id = cursor.getInteger("id"),
flags = cursor.getInteger("flags"),
eventType = cursor.getStringOrNull("event_type") ?: continue
)
)
}
}
return events
}
fun getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> {
val events = mutableMapOf<TrackerRuleEvent, TrackerRule>()
database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.conversation_id, tracker_rules.user_id " +
"FROM tracker_rules_events " +
"INNER JOIN tracker_rules " +
"ON tracker_rules_events.rule_id = tracker_rules.id " +
"WHERE event_type = ?", arrayOf(eventType)
).use { cursor ->
while (cursor.moveToNext()) {
val trackerRule = TrackerRule(
id = -1,
flags = cursor.getInteger("flags"),
conversationId = cursor.getStringOrNull("conversation_id"),
userId = cursor.getStringOrNull("user_id")
)
val trackerRuleEvent = TrackerRuleEvent(
id = cursor.getInteger("event_id"),
flags = cursor.getInteger("flags"),
eventType = cursor.getStringOrNull("event_type") ?: continue
)
events[trackerRuleEvent] = trackerRule
}
}
return events
}
}

View File

@ -15,23 +15,27 @@ class AutoReloadHandler(
private val lastModifiedMap = mutableMapOf<Uri, Long>() private val lastModifiedMap = mutableMapOf<Uri, Long>()
fun addFile(file: DocumentFile) { fun addFile(file: DocumentFile) {
files.add(file) synchronized(lastModifiedMap) {
lastModifiedMap[file.uri] = file.lastModified() files.add(file)
lastModifiedMap[file.uri] = file.lastModified()
}
} }
fun start() { fun start() {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
while (true) { while (true) {
files.forEach { file -> synchronized(lastModifiedMap) {
val lastModified = lastModifiedMap[file.uri] ?: return@forEach files.forEach { file ->
runCatching { val lastModified = lastModifiedMap[file.uri] ?: return@forEach
val newLastModified = file.lastModified() runCatching {
if (newLastModified > lastModified) { val newLastModified = file.lastModified()
lastModifiedMap[file.uri] = newLastModified if (newLastModified > lastModified) {
onReload(file) lastModifiedMap[file.uri] = newLastModified
onReload(file)
}
}.onFailure {
it.printStackTrace()
} }
}.onFailure {
it.printStackTrace()
} }
} }
delay(1000) delay(1000)

View File

@ -17,6 +17,10 @@ import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor
import me.rhunk.snapenhance.scripting.impl.IPCListeners import me.rhunk.snapenhance.scripting.impl.IPCListeners
import me.rhunk.snapenhance.scripting.impl.ManagerIPC import me.rhunk.snapenhance.scripting.impl.ManagerIPC
import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
import me.rhunk.snapenhance.storage.isScriptEnabled
import me.rhunk.snapenhance.storage.syncScripts
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -28,6 +32,10 @@ class RemoteScriptManager(
scripting = this@RemoteScriptManager scripting = this@RemoteScriptManager
} }
private val okHttpClient by lazy {
OkHttpClient.Builder().build()
}
private var autoReloadListener: AutoReloadListener? = null private var autoReloadListener: AutoReloadListener? = null
private val autoReloadHandler by lazy { private val autoReloadHandler by lazy {
AutoReloadHandler(context.coroutineScope) { AutoReloadHandler(context.coroutineScope) {
@ -49,6 +57,7 @@ class RemoteScriptManager(
private val ipcListeners = IPCListeners() private val ipcListeners = IPCListeners()
fun sync() { fun sync() {
cachedModuleInfo.clear()
getScriptFileNames().forEach { name -> getScriptFileNames().forEach { name ->
runCatching { runCatching {
getScriptInputStream(name) { stream -> getScriptInputStream(name) { stream ->
@ -63,7 +72,7 @@ class RemoteScriptManager(
} }
} }
context.modDatabase.syncScripts(cachedModuleInfo.values.toList()) context.database.syncScripts(cachedModuleInfo.values.toList())
} }
fun init() { fun init() {
@ -77,7 +86,11 @@ class RemoteScriptManager(
sync() sync()
enabledScripts.forEach { name -> enabledScripts.forEach { name ->
loadScript(name) runCatching {
loadScript(name)
}.onFailure {
context.log.error("Failed to load script $name", it)
}
} }
} }
@ -87,10 +100,10 @@ class RemoteScriptManager(
fun loadScript(path: String) { fun loadScript(path: String) {
val content = getScriptContent(path) ?: return val content = getScriptContent(path) ?: return
runtime.load(path, content)
if (context.config.root.scripting.autoReload.getNullable() != null) { if (context.config.root.scripting.autoReload.getNullable() != null) {
autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return) autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
} }
runtime.load(path, content)
} }
fun unloadScript(scriptPath: String) { fun unloadScript(scriptPath: String) {
@ -119,10 +132,38 @@ class RemoteScriptManager(
return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! } return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! }
} }
fun importFromUrl(
url: String
): ModuleInfo {
val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute()
if (!response.isSuccessful) {
throw Exception("Failed to fetch script. Code: ${response.code}")
}
response.body.byteStream().use { inputStream ->
val bufferedInputStream = inputStream.buffered()
bufferedInputStream.mark(0)
val moduleInfo = runtime.readModuleInfo(bufferedInputStream.bufferedReader())
bufferedInputStream.reset()
val scriptPath = moduleInfo.name + ".js"
val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath)
?: throw Exception("Failed to create script file")
context.androidContext.contentResolver.openOutputStream(scriptFile.uri)?.use { output ->
bufferedInputStream.copyTo(output)
}
sync()
loadScript(scriptPath)
runtime.removeModule(scriptPath)
return moduleInfo
}
}
override fun getEnabledScripts(): List<String> { override fun getEnabledScripts(): List<String> {
return runCatching { return runCatching {
getScriptFileNames().filter { getScriptFileNames().filter {
context.modDatabase.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false) context.database.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
} }
}.onFailure { }.onFailure {
context.log.error("Failed to get enabled scripts", it) context.log.error("Failed to get enabled scripts", it)

View File

@ -0,0 +1,93 @@
package me.rhunk.snapenhance.storage
import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
class AppDatabase(
val context: RemoteSideContext,
) {
val executor: ExecutorService = Executors.newSingleThreadExecutor()
lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
fun executeAsync(block: () -> Unit) {
executor.execute {
runCatching {
block()
}.onFailure {
context.log.error("Failed to execute async block", it)
}
}
}
fun init() {
database = context.androidContext.openOrCreateDatabase("main.db", 0, null)
SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf(
"friends" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"userId CHAR(36) UNIQUE",
"dmConversationId VARCHAR(36)",
"displayName VARCHAR",
"mutableUsername VARCHAR",
"bitmojiId VARCHAR",
"selfieId VARCHAR"
),
"groups" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"conversationId CHAR(36) UNIQUE",
"name VARCHAR",
"participantsCount INTEGER"
),
"rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"type VARCHAR",
"targetUuid VARCHAR"
),
"streaks" to listOf(
"id VARCHAR PRIMARY KEY",
"notify BOOLEAN",
"expirationTimestamp BIGINT",
"length INTEGER"
),
"scripts" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"name VARCHAR NOT NULL",
"version VARCHAR NOT NULL",
"displayName VARCHAR",
"description VARCHAR",
"author VARCHAR NOT NULL",
"enabled BOOLEAN"
),
"tracker_rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"enabled BOOLEAN DEFAULT 1",
"name VARCHAR",
),
"tracker_scopes" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"rule_id INTEGER",
"scope_type VARCHAR",
"scope_id CHAR(36)"
),
"tracker_rules_events" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"rule_id INTEGER",
"flags INTEGER DEFAULT 1",
"event_type VARCHAR",
"params TEXT",
"actions TEXT"
),
"quick_tiles" to listOf(
"key VARCHAR PRIMARY KEY",
"position INTEGER",
)
))
}
}

View File

@ -0,0 +1,181 @@
package me.rhunk.snapenhance.storage
import me.rhunk.snapenhance.common.data.FriendStreaks
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getGroups(): List<MessagingGroupInfo> {
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
val groups = mutableListOf<MessagingGroupInfo>()
while (cursor.moveToNext()) {
groups.add(MessagingGroupInfo.fromCursor(cursor))
}
groups
}
}
fun AppDatabase.getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor ->
val friends = mutableListOf<MessagingFriendInfo>()
while (cursor.moveToNext()) {
runCatching {
friends.add(MessagingFriendInfo.fromCursor(cursor))
}.onFailure {
context.log.error("Failed to parse friend", it)
}
}
friends
}
}
fun AppDatabase.syncGroupInfo(conversationInfo: MessagingGroupInfo) {
executeAsync {
try {
database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf(
conversationInfo.conversationId,
conversationInfo.name,
conversationInfo.participantsCount
))
} catch (e: Exception) {
throw e
}
}
}
fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
executeAsync {
try {
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
)
)
//sync streaks
friend.streaks?.takeIf { it.length > 0 }?.let {
val streaks = getFriendStreaks(friend.userId)
database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf(
friend.userId,
streaks?.notify ?: true,
it.expirationTimestamp,
it.length
))
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
} catch (e: Exception) {
throw e
}
}
}
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
targetUuid
)).use { cursor ->
val rules = mutableListOf<MessagingRuleType>()
while (cursor.moveToNext()) {
runCatching {
rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching)
}.onFailure {
context.log.error("Failed to parse rule", it)
}
}
rules
}
}
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
if (enabled) {
database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf(
targetUuid,
type
))
} else {
database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf(
targetUuid,
type
))
}
}
}
fun AppDatabase.getFriendInfo(userId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun AppDatabase.findFriend(conversationId: String): MessagingFriendInfo? {
return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingFriendInfo.fromCursor(cursor)
}
}
fun AppDatabase.deleteFriend(userId: String) {
executeAsync {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId))
}
}
fun AppDatabase.deleteGroup(conversationId: String) {
executeAsync {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId))
}
}
fun AppDatabase.getGroupInfo(conversationId: String): MessagingGroupInfo? {
return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
MessagingGroupInfo.fromCursor(cursor)
}
}
fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? {
return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor ->
if (!cursor.moveToFirst()) return@use null
FriendStreaks(
notify = cursor.getInteger("notify") == 1,
expirationTimestamp = cursor.getLongOrNull("expirationTimestamp") ?: 0L,
length = cursor.getInteger("length")
)
}
}
fun AppDatabase.setFriendStreaksNotify(userId: String, notify: Boolean) {
executeAsync {
database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf(
if (notify) 1 else 0,
userId
))
}
}
fun AppDatabase.getRuleIds(type: String): MutableList<String> {
return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
val ruleIds = mutableListOf<String>()
while (cursor.moveToNext()) {
ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
}
ruleIds
}
}

View File

@ -0,0 +1,26 @@
package me.rhunk.snapenhance.storage
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getQuickTiles(): List<String> {
return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor ->
val keys = mutableListOf<String>()
while (cursor.moveToNext()) {
keys.add(cursor.getStringOrNull("key") ?: continue)
}
keys
}
}
fun AppDatabase.setQuickTiles(keys: List<String>) {
executeAsync {
database.execSQL("DELETE FROM quick_tiles")
keys.forEachIndexed { index, key ->
database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf(
key,
index
))
}
}
}

View File

@ -0,0 +1,73 @@
package me.rhunk.snapenhance.storage
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
fun AppDatabase.getScripts(): List<ModuleInfo> {
return database.rawQuery("SELECT * FROM scripts ORDER BY id DESC", null).use { cursor ->
val scripts = mutableListOf<ModuleInfo>()
while (cursor.moveToNext()) {
scripts.add(
ModuleInfo(
name = cursor.getStringOrNull("name")!!,
version = cursor.getStringOrNull("version")!!,
displayName = cursor.getStringOrNull("displayName"),
description = cursor.getStringOrNull("description"),
author = cursor.getStringOrNull("author"),
grantedPermissions = emptyList()
)
)
}
scripts
}
}
fun AppDatabase.setScriptEnabled(name: String, enabled: Boolean) {
executeAsync {
database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf(
if (enabled) 1 else 0,
name
))
}
}
fun AppDatabase.isScriptEnabled(name: String): Boolean {
return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor ->
if (!cursor.moveToFirst()) return@use false
cursor.getInteger("enabled") == 1
}
}
fun AppDatabase.syncScripts(availableScripts: List<ModuleInfo>) {
runBlocking(executor.asCoroutineDispatcher()) {
val enabledScripts = getScripts()
val enabledScriptPaths = enabledScripts.map { it.name }
val availableScriptPaths = availableScripts.map { it.name }
enabledScripts.forEach { script ->
if (!availableScriptPaths.contains(script.name)) {
database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name))
}
}
availableScripts.forEach { script ->
if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) {
database.execSQL(
"INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf(
script.name,
script.version,
script.displayName,
script.description,
script.author,
0
)
)
}
}
}
}

View File

@ -0,0 +1,197 @@
package me.rhunk.snapenhance.storage
import android.content.ContentValues
import com.google.gson.JsonArray
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.data.TrackerRule
import me.rhunk.snapenhance.common.data.TrackerRuleAction
import me.rhunk.snapenhance.common.data.TrackerRuleActionParams
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
import me.rhunk.snapenhance.common.data.TrackerScopeType
import me.rhunk.snapenhance.common.util.ktx.getInteger
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import kotlin.coroutines.suspendCoroutine
fun AppDatabase.clearTrackerRules() {
runBlocking {
suspendCoroutine { continuation ->
executeAsync {
database.execSQL("DELETE FROM tracker_rules")
database.execSQL("DELETE FROM tracker_rules_events")
continuation.resumeWith(Result.success(Unit))
}
}
}
}
fun AppDatabase.deleteTrackerRule(ruleId: Int) {
executeAsync {
database.execSQL("DELETE FROM tracker_rules WHERE id = ?", arrayOf(ruleId))
database.execSQL("DELETE FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId))
}
}
fun AppDatabase.newTrackerRule(name: String = "Custom Rule"): Int {
return runBlocking {
suspendCoroutine { continuation ->
executeAsync {
val id = database.insert("tracker_rules", null, ContentValues().apply {
put("name", name)
})
continuation.resumeWith(Result.success(id.toInt()))
}
}
}
}
fun AppDatabase.addOrUpdateTrackerRuleEvent(
ruleEventId: Int? = null,
ruleId: Int? = null,
eventType: String? = null,
params: TrackerRuleActionParams,
actions: List<TrackerRuleAction>
): Int? {
return runBlocking {
suspendCoroutine { continuation ->
executeAsync {
val id = if (ruleEventId != null) {
database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf(
context.gson.toJson(params),
context.gson.toJson(actions.map { it.key }),
ruleEventId
))
ruleEventId
} else {
database.insert("tracker_rules_events", null, ContentValues().apply {
put("rule_id", ruleId)
put("event_type", eventType)
put("params", context.gson.toJson(params))
put("actions", context.gson.toJson(actions.map { it.key }))
}).toInt()
}
continuation.resumeWith(Result.success(id))
}
}
}
}
fun AppDatabase.deleteTrackerRuleEvent(eventId: Int) {
executeAsync {
database.execSQL("DELETE FROM tracker_rules_events WHERE id = ?", arrayOf(eventId))
}
}
fun AppDatabase.getTrackerRulesDesc(): List<TrackerRule> {
val rules = mutableListOf<TrackerRule>()
database.rawQuery("SELECT * FROM tracker_rules ORDER BY id DESC", null).use { cursor ->
while (cursor.moveToNext()) {
rules.add(
TrackerRule(
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
)
)
}
}
return rules
}
fun AppDatabase.getTrackerRule(ruleId: Int): TrackerRule? {
return database.rawQuery("SELECT * FROM tracker_rules WHERE id = ?", arrayOf(ruleId.toString())).use { cursor ->
if (!cursor.moveToFirst()) return@use null
TrackerRule(
id = cursor.getInteger("id"),
enabled = cursor.getInteger("enabled") == 1,
name = cursor.getStringOrNull("name") ?: "",
)
}
}
fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) {
executeAsync {
database.execSQL("UPDATE tracker_rules SET name = ? WHERE id = ?", arrayOf(name, ruleId))
}
}
fun AppDatabase.setTrackerRuleState(ruleId: Int, enabled: Boolean) {
executeAsync {
database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId))
}
}
fun AppDatabase.getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> {
val events = mutableListOf<TrackerRuleEvent>()
database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor ->
while (cursor.moveToNext()) {
events.add(
TrackerRuleEvent(
id = cursor.getInteger("id"),
eventType = cursor.getStringOrNull("event_type") ?: continue,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
TrackerRuleAction.fromString(it.asString)
}
)
)
}
}
return events
}
fun AppDatabase.getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> {
val events = mutableMapOf<TrackerRuleEvent, TrackerRule>()
database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," +
"tracker_rules_events.actions, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.name, tracker_rules.id as rule_id " +
"FROM tracker_rules_events " +
"INNER JOIN tracker_rules " +
"ON tracker_rules_events.rule_id = tracker_rules.id " +
"WHERE event_type = ? AND tracker_rules.enabled = 1", arrayOf(eventType)
).use { cursor ->
while (cursor.moveToNext()) {
val trackerRule = TrackerRule(
id = cursor.getInteger("rule_id"),
enabled = true,
name = cursor.getStringOrNull("name") ?: "",
)
val trackerRuleEvent = TrackerRuleEvent(
id = cursor.getInteger("event_id"),
eventType = cursor.getStringOrNull("event_type") ?: continue,
enabled = cursor.getInteger("flags") == 1,
params = context.gson.fromJson(cursor.getStringOrNull("event_params") ?: "{}", TrackerRuleActionParams::class.java),
actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull {
TrackerRuleAction.fromString(it.asString)
}
)
events[trackerRuleEvent] = trackerRule
}
}
return events
}
fun AppDatabase.setRuleTrackerScopes(ruleId: Int, type: TrackerScopeType, scopes: List<String>) {
executeAsync {
database.execSQL("DELETE FROM tracker_scopes WHERE rule_id = ?", arrayOf(ruleId))
scopes.forEach { scopeId ->
database.execSQL("INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)", arrayOf(
ruleId,
type.key,
scopeId
))
}
}
}
fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): Map<String, TrackerScopeType> {
val scopes = mutableMapOf<String, TrackerScopeType>()
database.rawQuery("SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())).use { cursor ->
while (cursor.moveToNext()) {
scopes[cursor.getStringOrNull("scope_id") ?: continue] = TrackerScopeType.entries.find { it.key == cursor.getStringOrNull("scope_type") } ?: continue
}
}
return scopes
}

View File

@ -78,7 +78,7 @@ class Navigation(
val currentRoute = routes.getCurrentRoute(navBackStackEntry) val currentRoute = routes.getCurrentRoute(navBackStackEntry)
primaryRoutes.forEach { route -> primaryRoutes.forEach { route ->
NavigationBarItem( NavigationBarItem(
alwaysShowLabel = false, alwaysShowLabel = true,
icon = { icon = {
Icon(imageVector = route.routeInfo.icon, contentDescription = null) Icon(imageVector = route.routeInfo.icon, contentDescription = null)
}, },
@ -88,7 +88,7 @@ class Navigation(
softWrap = false, softWrap = false,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.wrapContentWidth(unbounded = true), modifier = Modifier.wrapContentWidth(unbounded = true),
text = if (currentRoute == route) context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] else "", text = remember(context.translation.loadedLocale) { context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] },
) )
}, },
selected = currentRoute == route, selected = currentRoute == route,

View File

@ -15,7 +15,6 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.ui.manager.pages.FriendTrackerManagerRoot
import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
@ -27,6 +26,8 @@ import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories
import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
data class RouteInfo( data class RouteInfo(
@ -55,6 +56,7 @@ class Routes(
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home) val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home) val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home) val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home)
val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot()) val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social) val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)

View File

@ -1,330 +0,0 @@
package me.rhunk.snapenhance.ui.manager.pages
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog
import me.rhunk.snapenhance.common.data.TrackerEventType
import me.rhunk.snapenhance.common.data.TrackerRule
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
import java.text.DateFormat
@OptIn(ExperimentalFoundationApi::class)
class FriendTrackerManagerRoot : Routes.Route() {
enum class FilterType {
CONVERSATION, USERNAME, EVENT
}
private val titles = listOf("Logs", "Config Rules")
private var currentPage by mutableIntStateOf(0)
override val floatingActionButton: @Composable () -> Unit = {
if (currentPage == 1) {
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") },
expanded = false,
text = {},
onClick = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LogsTab() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf<TrackerLog>() }
var lastTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var filterType by remember { mutableStateOf(FilterType.USERNAME) }
var filter by remember { mutableStateOf("") }
var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
suspend fun loadNewLogs() {
withContext(Dispatchers.IO) {
logs.addAll(context.messageLogger.getLogs(lastTimestamp, filter = {
when (filterType) {
FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true)
FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup)
FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true)
}
}).apply {
lastTimestamp = minOfOrNull { it.timestamp } ?: lastTimestamp
})
}
}
suspend fun resetAndLoadLogs() {
logs.clear()
lastTimestamp = Long.MAX_VALUE
loadNewLogs()
}
Column(
modifier = Modifier.fillMaxSize()
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var showAutoComplete by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = showAutoComplete, onExpandedChange = { showAutoComplete = it }) {
TextField(
value = filter,
onValueChange = {
filter = it
coroutineScope.launch {
searchTimeoutJob?.cancel()
searchTimeoutJob = coroutineScope.launch {
delay(200)
showAutoComplete = true
resetAndLoadLogs()
}
}
},
placeholder = { Text("Search") },
maxLines = 1,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
modifier = Modifier
.weight(1F)
.menuAnchor()
.padding(8.dp)
)
DropdownMenu(expanded = showAutoComplete, onDismissRequest = {
showAutoComplete = false
}, properties = PopupProperties(focusable = false)) {
val suggestedEntries = remember(filter) {
mutableStateListOf<String>()
}
LaunchedEffect(filter) {
suggestedEntries.addAll(when (filterType) {
FilterType.USERNAME -> context.messageLogger.findUsername(filter)
FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter)
FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key }
}.take(5))
}
suggestedEntries.forEach { entry ->
DropdownMenuItem(onClick = {
filter = entry
coroutineScope.launch {
resetAndLoadLogs()
}
showAutoComplete = false
}, text = {
Text(entry)
})
}
}
}
var dropDownExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = dropDownExpanded, onExpandedChange = { dropDownExpanded = it }) {
ElevatedCard(
modifier = Modifier.menuAnchor()
) {
Text("Filter " + filterType.name, modifier = Modifier.padding(8.dp))
}
DropdownMenu(expanded = dropDownExpanded, onDismissRequest = {
dropDownExpanded = false
}) {
FilterType.entries.forEach { type ->
DropdownMenuItem(onClick = {
filterType = type
dropDownExpanded = false
coroutineScope.launch {
resetAndLoadLogs()
}
}, text = {
Text(type.name)
})
}
}
}
}
LazyColumn(
modifier = Modifier.weight(1f)
) {
item {
if (logs.isEmpty()) {
Text("No logs found", modifier = Modifier.padding(16.dp).fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light)
}
}
items(logs) { log ->
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier
.weight(1f)
) {
Text(log.username + " " + log.eventType + " in " + log.conversationTitle)
Text(
DateFormat.getDateTimeInstance().format(log.timestamp),
fontSize = 12.sp,
fontWeight = FontWeight.Light
)
}
OutlinedIconButton(
onClick = {
}
) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(lastTimestamp) {
loadNewLogs()
}
}
}
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun ConfigRulesTab() {
val rules = remember { mutableStateListOf<TrackerRule>() }
Column(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(rules) { rule ->
val events = remember(rule.id) {
mutableStateListOf<TrackerRuleEvent>()
}
LaunchedEffect(rule.id) {
withContext(Dispatchers.IO) {
events.addAll(context.modDatabase.getTrackerEvents(rule.id))
}
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text("Rule: ${rule.id} - conversationId: ${rule.conversationId?.let { "present" } ?: "none" } - userId: ${rule.userId?.let { "present" } ?: "none"}")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
events.forEach { event ->
Text("${event.eventType} - ${event.flags}")
}
}
}
}
}
}
}
LaunchedEffect(Unit) {
rules.addAll(context.modDatabase.getTrackerRules(null, null))
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
currentPage = pagerState.currentPage
Column {
TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(
pagerState = pagerState,
tabPositions = tabPositions
)
)
}) {
titles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
state = pagerState
) { page ->
when (page) {
0 -> LogsTab()
1 -> ConfigRulesTab()
}
}
}
}
}

View File

@ -17,9 +17,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -33,14 +35,19 @@ import me.rhunk.snapenhance.common.data.download.DownloadMetadata
import me.rhunk.snapenhance.common.data.download.DownloadRequest import me.rhunk.snapenhance.common.data.download.DownloadRequest
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
import me.rhunk.snapenhance.common.data.download.createNewFilePath import me.rhunk.snapenhance.common.data.download.createNewFilePath
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.storage.findFriend
import me.rhunk.snapenhance.storage.getFriendInfo
import me.rhunk.snapenhance.storage.getGroupInfo
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.text.DateFormat
import java.util.UUID import java.util.UUID
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -114,7 +121,7 @@ class LoggerHistoryRoot : Routes.Route() {
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(4.dp) .padding(8.dp)
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -123,7 +130,7 @@ class LoggerHistoryRoot : Routes.Route() {
LaunchedEffect(Unit, message) { LaunchedEffect(Unit, message) {
runCatching { runCatching {
decodeMessage(message) { senderId, contentType, messageReader, attachments -> decodeMessage(message) { senderId, contentType, messageReader, attachments ->
val senderUsername = senderId?.let { context.modDatabase.getFriendInfo(it)?.mutableUsername } ?: translation["unknown_sender"] val senderUsername = senderId?.let { context.database.getFriendInfo(it)?.mutableUsername } ?: translation["unknown_sender"]
@Composable @Composable
fun ContentHeader() { fun ContentHeader() {
@ -141,6 +148,26 @@ class LoggerHistoryRoot : Routes.Route() {
context.androidContext.copyToClipboard(content) context.androidContext.copyToClipboard(content)
}) })
}) })
val edits by rememberAsyncMutableState(defaultValue = emptyList()) {
loggerWrapper.getMessageEdits(selectedConversation!!, message.messageId)
}
edits.forEach { messageEdit ->
val date = remember {
DateFormat.getDateTimeInstance().format(messageEdit.timestamp)
}
Text(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(onLongPress = {
context.androidContext.copyToClipboard(messageEdit.messageText)
})
}.fillMaxWidth().padding(start = 4.dp),
text = messageEdit.messageText + " (edited at $date)",
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic,
fontSize = 12.sp
)
}
ContentHeader() ContentHeader()
} }
} }
@ -209,9 +236,9 @@ class LoggerHistoryRoot : Routes.Route() {
) { ) {
fun formatConversationId(conversationId: String?): String? { fun formatConversationId(conversationId: String?): String? {
if (conversationId == null) return null if (conversationId == null) return null
return context.modDatabase.getGroupInfo(conversationId)?.name?.let { return context.database.getGroupInfo(conversationId)?.name?.let {
translation.format("list_group_format", "name" to it) translation.format("list_group_format", "name" to it)
} ?: context.modDatabase.findFriend(conversationId)?.let { } ?: context.database.findFriend(conversationId)?.let {
translation.format("list_friend_format", "name" to (it.displayName?.let { name -> "$name (${it.mutableUsername})" } ?: it.mutableUsername)) translation.format("list_friend_format", "name" to (it.displayName?.let { name -> "$name (${it.mutableUsername})" } ?: it.mutableUsername))
} ?: conversationId } ?: conversationId
} }
@ -225,13 +252,8 @@ class LoggerHistoryRoot : Routes.Route() {
.fillMaxWidth() .fillMaxWidth()
) )
val conversations = remember { mutableStateListOf<String>() } val conversations by rememberAsyncMutableState(defaultValue = emptyList()) {
loggerWrapper.getAllConversations().toMutableList()
LaunchedEffect(Unit) {
conversations.clear()
withContext(Dispatchers.IO) {
conversations.addAll(loggerWrapper.getAllConversations())
}
} }
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {

View File

@ -16,7 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -28,6 +28,7 @@ import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.download.DownloadMetadata import me.rhunk.snapenhance.common.data.download.DownloadMetadata
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
import me.rhunk.snapenhance.common.data.download.createNewFilePath import me.rhunk.snapenhance.common.data.download.createNewFilePath
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.download.FFMpegProcessor import me.rhunk.snapenhance.download.FFMpegProcessor
@ -133,34 +134,46 @@ class TasksRoot : Routes.Route() {
} }
} }
override val topBarActions: @Composable() (RowScope.() -> Unit) = { override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showConfirmDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { if (taskSelection.size == 1) {
taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile -> val selectionExists by rememberAsyncMutableState(defaultValue = false) {
IconButton(onClick = { taskSelection.firstOrNull()?.second?.exists() == true
runCatching { }
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { if (selectionExists) {
setDataAndType(documentFile.uri, documentFile.type) taskSelection.firstOrNull()?.second?.let { documentFile ->
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK IconButton(onClick = {
}) runCatching {
taskSelection.clear() context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
}.onFailure { setDataAndType(documentFile.uri, documentFile.type)
context.log.error("Failed to open file ${taskSelection.first().second}", it) flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
})
taskSelection.clear()
}.onFailure {
context.log.error("Failed to open file ${taskSelection.first().second}", it)
}
}) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open")
} }
}) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open")
} }
} }
} }
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { if (taskSelection.size > 1) {
IconButton(onClick = { val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
mergeSelection(taskSelection.toList().also { taskSelection.all { it.second?.type?.contains("video") == true }
taskSelection.clear() }
}.map { it.first to it.second!! })
}) { if (canMergeSelection) {
Icon(Icons.Filled.Merge, contentDescription = "Merge") IconButton(onClick = {
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
}) {
Icon(Icons.Filled.Merge, contentDescription = "Merge")
}
} }
} }
@ -187,9 +200,12 @@ class TasksRoot : Routes.Route() {
if (taskSelection.isNotEmpty()) { if (taskSelection.isNotEmpty()) {
Text(translation["remove_selected_tasks_title"]) Text(translation["remove_selected_tasks_title"])
Row ( Row (
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable { modifier = Modifier
alsoDeleteFiles = !alsoDeleteFiles .padding(top = 10.dp)
}, .fillMaxWidth()
.clickable {
alsoDeleteFiles = !alsoDeleteFiles
},
horizontalArrangement = Arrangement.spacedBy(5.dp), horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -207,19 +223,22 @@ class TasksRoot : Routes.Route() {
Button( Button(
onClick = { onClick = {
showConfirmDialog = false showConfirmDialog = false
if (taskSelection.isNotEmpty()) { if (taskSelection.isNotEmpty()) {
taskSelection.forEach { (task, documentFile) -> taskSelection.forEach { (task, documentFile) ->
context.taskManager.removeTask(task) coroutineScope.launch(Dispatchers.IO) {
recentTasks.remove(task) context.taskManager.removeTask(task)
if (alsoDeleteFiles) { if (alsoDeleteFiles) {
documentFile?.delete() documentFile?.delete()
}
} }
recentTasks.remove(task)
} }
activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
taskSelection.clear() taskSelection.clear()
} else { } else {
context.taskManager.clearAllTasks() coroutineScope.launch(Dispatchers.IO) {
context.taskManager.clearAllTasks()
}
recentTasks.clear() recentTasks.clear()
activeTasks.forEach { activeTasks.forEach {
runCatching { runCatching {
@ -255,16 +274,17 @@ class TasksRoot : Routes.Route() {
var taskProgressLabel by remember { mutableStateOf<String?>(null) } var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) } var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
var documentFile by remember { mutableStateOf<DocumentFile?>(null) }
var isDocumentFileReadable by remember { mutableStateOf(true) }
LaunchedEffect(taskStatus.key) { var documentFileMimeType by remember { mutableStateOf("") }
launch(Dispatchers.IO) { var isDocumentFileReadable by remember { mutableStateOf(true) }
documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch) val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
isDocumentFileReadable = documentFile?.canRead() ?: false DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
} }
} }
val listener = remember { PendingTaskListener( val listener = remember { PendingTaskListener(
onStateChange = { onStateChange = {
taskStatus = it taskStatus = it
@ -285,19 +305,21 @@ class TasksRoot : Routes.Route() {
} }
} }
OutlinedCard(modifier = modifier.clickable { OutlinedCard(modifier = modifier
if (isSelected) { .clickable {
taskSelection.removeIf { it.first == task } if (isSelected) {
return@clickable taskSelection.removeIf { it.first == task }
return@clickable
}
taskSelection.add(task to documentFile)
} }
taskSelection.add(task to documentFile) .let {
}.let { if (isSelected) {
if (isSelected) { it
it .border(2.dp, MaterialTheme.colorScheme.primary)
.border(2.dp, MaterialTheme.colorScheme.primary) .clip(MaterialTheme.shapes.medium)
.clip(MaterialTheme.shapes.medium) } else it
} else it }) {
}) {
Row( Row(
modifier = Modifier.padding(15.dp), modifier = Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -305,13 +327,12 @@ class TasksRoot : Routes.Route() {
Column( Column(
modifier = Modifier.padding(end = 15.dp) modifier = Modifier.padding(end = 15.dp)
) { ) {
documentFile?.let { file -> documentFile?.let {
val mimeType = file.type ?: ""
when { when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
} }
} ?: run { } ?: run {

View File

@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -45,7 +44,6 @@ import me.rhunk.snapenhance.ui.manager.MainActivity
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.* import me.rhunk.snapenhance.ui.util.*
@OptIn(ExperimentalMaterial3Api::class)
class FeaturesRoot : Routes.Route() { class FeaturesRoot : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) } private val alertDialogs by lazy { AlertDialogs(context.translation) }
@ -313,7 +311,7 @@ class FeaturesRoot : Routes.Route() {
FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722), FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
) )
Card( ElevatedCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
@ -327,24 +325,14 @@ class FeaturesRoot : Routes.Route() {
.padding(all = 4.dp), .padding(all = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
property.key.params.icon?.let { iconName -> property.key.params.icon?.let { icon ->
//TODO: find a better way to load icons Icon(
val icon: ImageVector? = remember(iconName) { imageVector = icon,
runCatching { contentDescription = null,
val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt") modifier = Modifier
val method = cl.declaredMethods.first() .align(Alignment.CenterVertically)
method.invoke(null, Icons.Filled) as ImageVector .padding(start = 10.dp)
}.getOrNull() )
}
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(start = 10.dp)
)
}
} }
Column( Column(

View File

@ -31,6 +31,7 @@ import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.LogReader
import me.rhunk.snapenhance.common.logger.LogChannel import me.rhunk.snapenhance.common.logger.LogChannel
@ -64,8 +65,12 @@ class HomeLogs : Routes.Route() {
modifier = Modifier.align(Alignment.CenterVertically) modifier = Modifier.align(Alignment.CenterVertically)
) { ) {
DropdownMenuItem(onClick = { DropdownMenuItem(onClick = {
context.log.clearLogs() context.coroutineScope.launch {
navigate() context.log.clearLogs()
}
routes.navController.navigate(routeInfo.id) {
popUpTo(routeInfo.id) { inclusive = true }
}
showDropDown = false showDropDown = false
}, text = { }, text = {
Text(translation["clear_logs_button"]) Text(translation["clear_logs_button"])
@ -148,63 +153,73 @@ class HomeLogs : Routes.Route() {
} }
} }
items(lineCount) { index -> items(lineCount) { index ->
val logLine = remember(index) { logReader?.getLogLine(index) } ?: return@items val logLine by remember(index) {
mutableStateOf(runBlocking(Dispatchers.IO) {
logReader?.getLogLine(index)
})
}
var expand by remember { mutableStateOf(false) } var expand by remember { mutableStateOf(false) }
Box(modifier = Modifier logLine?.let { line ->
.fillMaxWidth() Box(modifier = Modifier
.pointerInput(Unit) { .fillMaxWidth()
detectTapGestures( .pointerInput(Unit) {
onLongPress = { detectTapGestures(
coroutineScope.launch { onLongPress = {
clipboardManager.setText(AnnotatedString(logLine.message)) coroutineScope.launch {
} clipboardManager.setText(
}, AnnotatedString(
onTap = { line.message
expand = !expand )
} )
) }
}) {
Row(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = 30.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (!expand) {
Icon(
imageVector = when (logLine.logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
}, },
contentDescription = null, onTap = {
expand = !expand
}
) )
}) {
Row(
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = 30.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (!expand) {
Icon(
imageVector = when (line.logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
else -> Icons.Outlined.Info
},
contentDescription = null,
)
Text(
text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag,
modifier = Modifier.padding(start = 4.dp),
fontWeight = FontWeight.Light,
fontSize = 10.sp,
)
Text(
text = line.dateTime,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
fontSize = 10.sp
)
}
Text( Text(
text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag, text = line.message.trimIndent(),
modifier = Modifier.padding(start = 4.dp),
fontWeight = FontWeight.Light,
fontSize = 10.sp, fontSize = 10.sp,
) maxLines = if (expand) Int.MAX_VALUE else 6,
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
Text( softWrap = !expand,
text = logLine.dateTime,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
fontSize = 10.sp
) )
} }
Text(
text = logLine.message.trimIndent(),
fontSize = 10.sp,
maxLines = if (expand) Int.MAX_VALUE else 6,
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
softWrap = !expand,
)
} }
} }
} }

View File

@ -2,42 +2,47 @@ package me.rhunk.snapenhance.ui.manager.pages.home
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PersonSearch
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.BuildConfig
import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.storage.getQuickTiles
import me.rhunk.snapenhance.storage.setQuickTiles
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.Updater import me.rhunk.snapenhance.ui.manager.data.Updater
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
class HomeRoot : Routes.Route() { class HomeRoot : Routes.Route() {
companion object { companion object {
@ -46,61 +51,33 @@ class HomeRoot : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = { private fun launchActionIntent(action: EnumAction) {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key)
context.androidContext.startActivity(intent)
} }
@Composable private val cards by lazy {
private fun SummaryCards(installationSummary: InstallationSummary) { mapOf(
val summaryInfo = remember { ("Friend Tracker" to Icons.Default.PersonSearch) to {
mapOf( routes.friendTracker.navigateReset()
"Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), },
"Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"), ("Logger History" to Icons.Default.History) to {
"Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"), routes.loggerHistory.navigateReset()
"Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"), },
"Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"), ).toMutableMap().apply {
"Device" to installationSummary.platformInfo.device, EnumAction.entries.forEach { action ->
"Android Version" to installationSummary.platformInfo.androidVersion, this[context.translation["actions.${action.key}.name"] to action.icon] = {
"System ABI" to installationSummary.platformInfo.systemAbi launchActionIntent(action)
)
}
Card(
modifier = Modifier
.padding(all = cardMargin)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
) {
summaryInfo.forEach { (title, value) ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 5.dp),
) {
Text(
text = title,
fontSize = 12.sp,
fontWeight = FontWeight.Light,
)
Text(
fontSize = 14.sp,
text = value,
lineHeight = 20.sp
)
}
} }
} }
} }
} }
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity !!)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = { override val topBarActions: @Composable (RowScope.() -> Unit) = {
IconButton(onClick = { IconButton(onClick = {
routes.homeLogs.navigate() routes.homeLogs.navigate()
@ -114,6 +91,36 @@ class HomeRoot : Routes.Route() {
} }
} }
@Composable
fun LinkIcon(
modifier: Modifier = Modifier,
size: Dp = 32.dp,
imageVector: ImageVector,
dataArray: IntArray
) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(size)
.then(modifier)
.clickable {
context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(
dataArray
.map { it.toChar() }
.joinToString("")
.reversed()
)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
})
}
)
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNextFontFamily = remember { val avenirNextFontFamily = remember {
FontFamily( FontFamily(
@ -121,26 +128,17 @@ class HomeRoot : Routes.Route() {
) )
} }
var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize()
.verticalScroll(ScrollState(0)) .verticalScroll(ScrollState(0))
) { ) {
Image(
painter = painterResource(id = R.drawable.launcher_icon_monochrome),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentScale = ContentScale.FillHeight,
modifier = Modifier
.fillMaxWidth()
.scale(1.8f)
.height(90.dp)
)
Text( Text(
text = remember { intArrayOf(101,99,110,97,104,110,69,112,97,110,83).map { it.toChar() }.joinToString("").reversed() }, text = remember {
intArrayOf(
101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83
).map { it.toChar() }.joinToString("").reversed()
},
fontSize = 30.sp, fontSize = 30.sp,
fontFamily = avenirNextFontFamily, fontFamily = avenirNextFontFamily,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
@ -153,50 +151,48 @@ class HomeRoot : Routes.Route() {
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
Text(
text = "An xposed module made to enhance your Snapchat experience",
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(
modifier = Modifier 15.dp, Alignment.CenterHorizontally
), modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(all = 10.dp) .padding(all = 10.dp)
) { ) {
Icon( LinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
contentDescription = null, dataArray = intArrayOf(
tint = MaterialTheme.colorScheme.onSurfaceVariant, 101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83, 47, 107, 110,
modifier = Modifier.size(32.dp).clickable { 117, 104, 114, 47, 109, 111, 99, 46, 98, 117, 104, 116, 105,
context.activity?.startActivity( 103, 47, 58, 115, 112, 116, 116, 104
Intent(Intent.ACTION_VIEW).apply { )
data = Uri.parse(
intArrayOf(101,99,110,97,104,110,69,112,97,110,83,47,107,110,117,104,114,47,109,111,99,46,98,117,104,116,105,103,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed()
)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
)
}
) )
Icon(
LinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
contentDescription = null, dataArray = intArrayOf(
tint = MaterialTheme.colorScheme.onSurfaceVariant, 101, 99, 110, 97, 104, 110, 101, 112, 97, 110, 115, 47, 101,
modifier = Modifier.size(32.dp).clickable { 109, 46, 116, 47, 47, 58, 115, 112, 116, 116, 104
context.activity?.startActivity( )
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(
intArrayOf(101,99,110,97,104,110,101,112,97,110,115,47,101,109,46,116,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed()
)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
)
}
) )
LinkIcon(
size = 36.dp,
modifier = Modifier.offset(y = (-2).dp),
imageVector = Icons.AutoMirrored.Default.Help,
dataArray = intArrayOf(
105, 107, 105, 119, 47, 101, 99, 110, 97, 104, 110, 69, 112, 97,
110, 83, 47, 107, 110, 117, 104, 114, 47, 109, 111, 99, 46, 98,
117, 104, 116, 105, 103, 47, 47, 58, 115, 112, 116, 116, 104
)
)
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles()
}
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
if (!BuildConfig.DEBUG) Updater.checkForLatestRelease() else null
} }
if (latestUpdate != null) { if (latestUpdate != null) {
@ -209,7 +205,7 @@ class HomeRoot : Routes.Route() {
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) )
){ ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -223,17 +219,16 @@ class HomeRoot : Routes.Route() {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
fontSize = 12.sp, fontSize = 12.sp, text = translation.format(
text = translation.format("update_content", "version" to (latestUpdate?.versionName ?: "unknown")), "update_content",
lineHeight = 20.sp "version" to (latestUpdate?.versionName ?: "unknown")
), lineHeight = 20.sp
) )
} }
Button(onClick = { Button(onClick = {
context.activity?.startActivity( context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(latestUpdate?.releaseUrl)
data = Uri.parse(latestUpdate?.releaseUrl) })
}
)
}, modifier = Modifier.height(40.dp)) { }, modifier = Modifier.height(40.dp)) {
Text(text = translation["update_button"]) Text(text = translation["update_button"])
} }
@ -241,38 +236,93 @@ class HomeRoot : Routes.Route() {
} }
} }
val coroutineScope = rememberCoroutineScope() var showQuickActionsMenu by remember { mutableStateOf(false) }
var installationSummary by remember { mutableStateOf(null as InstallationSummary?) }
fun updateInstallationSummary(scope: CoroutineScope) { Row(
scope.launch(Dispatchers.IO) { modifier = Modifier
runCatching { .fillMaxWidth()
installationSummary = context.installationSummary .padding(start = 20.dp, end = 30.dp, top = 20.dp),
}.onFailure { verticalAlignment = Alignment.CenterVertically
context.longToast("SnapEnhance failed to load installation summary: ${it.message}") ) {
Text("Quick Actions", fontSize = 20.sp, modifier = Modifier.weight(1f))
Box {
IconButton(
onClick = { showQuickActionsMenu = !showQuickActionsMenu },
) {
Icon(Icons.Default.MoreVert, contentDescription = null)
} }
runCatching { DropdownMenu(
if (!BuildConfig.DEBUG) { expanded = showQuickActionsMenu,
latestUpdate = Updater.checkForLatestRelease() onDismissRequest = { showQuickActionsMenu = false }
) {
cards.forEach { (card, _) ->
fun toggle(state: Boolean? = null) {
if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
selectedTiles.remove(card.first)
} else {
selectedTiles.add(0, card.first)
}
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}
}
DropdownMenuItem(onClick = { toggle() }, text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(all = 5.dp)
) {
Checkbox(
checked = selectedTiles.contains(card.first),
onCheckedChange = {
toggle(it)
}
)
Text(text = card.first)
}
})
}
}
}
}
FlowRow(
modifier = Modifier
.padding(all = cardMargin)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
remember(selectedTiles.size, context.translation.loadedLocale) { selectedTiles.mapNotNull {
cards.entries.find { entry -> entry.key.first == it }
} }.forEach { (card, action) ->
ElevatedCard(
modifier = Modifier
.size(105.dp)
.weight(1f)
.clickable { action() }
.padding(all = 6.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 5.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(50.dp)
)
Text(
lineHeight = 16.sp, text = card.first, fontSize = 11.sp,
textAlign = TextAlign.Center
)
} }
}.onFailure {
context.longToast("SnapEnhance failed to check for updates: ${it.message}")
} }
} }
} }
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
updateInstallationSummary(coroutineScope)
}
}
LaunchedEffect(Unit) {
updateInstallationSummary(coroutineScope)
}
Spacer(modifier = Modifier.height(20.dp))
installationSummary?.let { SummaryCards(installationSummary = it) }
} }
} }
} }

View File

@ -18,10 +18,12 @@ import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.setup.Requirements import me.rhunk.snapenhance.ui.setup.Requirements
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
@ -165,13 +167,11 @@ class HomeSettings : Routes.Route() {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
var storedMessagesCount by remember { mutableIntStateOf(0) } var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
var storedStoriesCount by remember { mutableIntStateOf(0) } context.messageLogger.getStoredMessageCount()
LaunchedEffect(Unit) { }
withContext(Dispatchers.IO) { var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
storedMessagesCount = context.messageLogger.getStoredMessageCount() context.messageLogger.getStoredStoriesCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
}
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
@ -273,7 +273,9 @@ class HomeSettings : Routes.Route() {
} }
Button(onClick = { Button(onClick = {
runCatching { runCatching {
selectedFileType.resolve(context.androidContext).delete() context.coroutineScope.launch {
selectedFileType.resolve(context.androidContext).delete()
}
}.onFailure { }.onFailure {
context.log.error("Failed to clear file", it) context.log.error("Failed to clear file", it)
context.longToast("Failed to clear file! ${it.localizedMessage}") context.longToast("Failed to clear file! ${it.localizedMessage}")

View File

@ -6,17 +6,21 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -26,8 +30,14 @@ import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.storage.getScripts
import me.rhunk.snapenhance.storage.isScriptEnabled
import me.rhunk.snapenhance.storage.setScriptEnabled
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.Dialog
import me.rhunk.snapenhance.ui.util.chooseFolder import me.rhunk.snapenhance.ui.util.chooseFolder
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
@ -35,19 +45,213 @@ import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
class ScriptingRoot : Routes.Route() { class ScriptingRoot : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
@Composable
private fun ImportRemoteScript(
dismiss: () -> Unit
) {
Dialog(onDismissRequest = dismiss) {
var url by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
var isLoading by remember {
mutableStateOf(false)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Import Script from URL",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(8.dp),
)
Text(
text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.",
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(8.dp),
textAlign = TextAlign.Center,
)
TextField(
value = url,
onValueChange = {
url = it
},
label = {
Text(text = "Enter URL here:")
},
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onGloballyPositioned {
focusRequester.requestFocus()
}
)
Spacer(modifier = Modifier.height(8.dp))
Button(
enabled = url.isNotBlank(),
onClick = {
isLoading = true
context.coroutineScope.launch {
runCatching {
val moduleInfo = context.scriptManager.importFromUrl(url)
context.shortToast("Script ${moduleInfo.name} imported!")
reloadDispatcher.dispatch()
withContext(Dispatchers.Main) {
dismiss()
}
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast("Failed to import script. ${it.message}. Check logs for more details")
}
isLoading = false
}
},
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.size(30.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text = "Import")
}
}
}
}
}
}
@Composable
private fun ModuleActions(
script: ModuleInfo,
dismiss: () -> Unit
) {
Dialog(
onDismissRequest = dismiss,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
val actions = remember {
mapOf<Pair<String, ImageVector>, suspend () -> Unit>(
("Edit Module" to Icons.Default.Edit) to {
runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!!
context.androidContext.startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = context.scriptManager.getScriptsFolder()!!
.findFile(modulePath)!!.uri
flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
)
dismiss()
}.onFailure {
context.log.error("Failed to open module file", it)
context.shortToast("Failed to open module file. Check logs for more details")
}
},
("Clear Module Data" to Icons.Default.Save) to {
runCatching {
context.scriptManager.getModuleDataFolder(script.name)
.deleteRecursively()
context.shortToast("Module data cleared!")
dismiss()
}.onFailure {
context.log.error("Failed to clear module data", it)
context.shortToast("Failed to clear module data. Check logs for more details")
}
},
("Delete Module" to Icons.Default.DeleteOutline) to {
context.scriptManager.apply {
runCatching {
val modulePath = getModulePath(script.name)!!
unloadScript(modulePath)
getScriptsFolder()?.findFile(modulePath)?.delete()
reloadDispatcher.dispatch()
context.shortToast("Deleted script ${script.name}!")
dismiss()
}.onFailure {
context.log.error("Failed to delete module", it)
context.shortToast("Failed to delete module. Check logs for more details")
}
}
}
)
}
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
item {
Text(
text = "Actions",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
items(actions.size) { index ->
val action = actions.entries.elementAt(index)
ListItem(
modifier = Modifier
.clickable {
context.coroutineScope.launch {
action.value()
dismiss()
}
}
.fillMaxWidth(),
leadingContent = {
Icon(
imageVector = action.key.second,
contentDescription = action.key.first
)
},
headlineContent = {
Text(text = action.key.first)
},
)
}
}
}
}
}
@Composable @Composable
fun ModuleItem(script: ModuleInfo) { fun ModuleItem(script: ModuleInfo) {
var enabled by remember { var enabled by rememberAsyncMutableState(defaultValue = false) {
mutableStateOf(context.modDatabase.isScriptEnabled(script.name)) context.database.isScriptEnabled(script.name)
} }
var openSettings by remember { var openSettings by remember {
mutableStateOf(false) mutableStateOf(false)
} }
var openActions by remember {
mutableStateOf(false)
}
Card( Card(
modifier = Modifier modifier = Modifier
@ -59,43 +263,64 @@ class ScriptingRoot : Routes.Route() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
if (!enabled) return@clickable
openSettings = !openSettings openSettings = !openSettings
} }
.padding(8.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (enabled) {
Icon(
imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier
.padding(end = 8.dp)
.size(32.dp),
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(end = 8.dp) .padding(end = 8.dp)
) { ) {
Text(text = script.displayName ?: script.name, fontSize = 20.sp,) Text(text = script.displayName ?: script.name, fontSize = 20.sp)
Text(text = script.description ?: "No description", fontSize = 14.sp,) Text(text = script.description ?: "No description", fontSize = 14.sp)
} }
IconButton(onClick = { openSettings = !openSettings }) { IconButton(onClick = {
Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) openActions = !openActions
}) {
Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
} }
Switch( Switch(
checked = enabled, checked = enabled,
onCheckedChange = { isChecked -> onCheckedChange = { isChecked ->
context.modDatabase.setScriptEnabled(script.name, isChecked) openSettings = false
enabled = isChecked context.coroutineScope.launch(Dispatchers.IO) {
runCatching { runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!! val modulePath = context.scriptManager.getModulePath(script.name)!!
context.scriptManager.unloadScript(modulePath) context.scriptManager.unloadScript(modulePath)
if (isChecked) { if (isChecked) {
context.scriptManager.loadScript(modulePath) context.scriptManager.loadScript(modulePath)
context.scriptManager.runtime.getModuleByName(script.name) context.scriptManager.runtime.getModuleByName(script.name)
?.callFunction("module.onSnapEnhanceLoad") ?.callFunction("module.onSnapEnhanceLoad")
context.shortToast("Loaded script ${script.name}") context.shortToast("Loaded script ${script.name}")
} else { } else {
context.shortToast("Unloaded script ${script.name}") context.shortToast("Unloaded script ${script.name}")
} }
}.onFailure { throwable ->
enabled = !isChecked context.database.setScriptEnabled(script.name, isChecked)
("Failed to ${if (isChecked) "enable" else "disable"} script").let { withContext(Dispatchers.Main) {
context.log.error(it, throwable) enabled = isChecked
context.shortToast(it) }
}.onFailure { throwable ->
withContext(Dispatchers.Main) {
enabled = !isChecked
}
("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also {
context.log.error(it, throwable)
context.shortToast(it)
}
} }
} }
} }
@ -106,18 +331,31 @@ class ScriptingRoot : Routes.Route() {
ScriptSettings(script) ScriptSettings(script)
} }
} }
if (openActions) {
ModuleActions(script) { openActions = false }
}
} }
override val floatingActionButton: @Composable () -> Unit = { override val floatingActionButton: @Composable () -> Unit = {
var showImportDialog by remember {
mutableStateOf(false)
}
if (showImportDialog) {
ImportRemoteScript {
showImportDialog = false
}
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { onClick = {
showImportDialog = true
}, },
icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
text = { text = {
Text(text = "Import from URL") Text(text = "Import from URL")
}, },
@ -133,7 +371,12 @@ class ScriptingRoot : Routes.Route() {
) )
} }
}, },
icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, icon = {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = "Folder"
)
},
text = { text = {
Text(text = "Open Scripts Folder") Text(text = "Open Scripts Folder")
}, },
@ -144,8 +387,9 @@ class ScriptingRoot : Routes.Route() {
@Composable @Composable
fun ScriptSettings(script: ModuleInfo) { fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember { val settingsInterface = remember {
val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null val module =
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS) (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
} }
@ -155,43 +399,44 @@ class ScriptingRoot : Routes.Route() {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
} else { } else {
ScriptInterface(interfaceBuilder = settingsInterface) ScriptInterface(interfaceBuilder = settingsInterface)
} }
} }
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } val scriptingFolder by rememberAsyncMutableState(
var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } defaultValue = null,
updateDispatcher = reloadDispatcher
) {
context.scriptManager.getScriptsFolder()
}
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
updateDispatcher = reloadDispatcher
) {
context.scriptManager.sync()
context.database.getScripts()
}
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var refreshing by remember { var refreshing by remember {
mutableStateOf(false) mutableStateOf(false)
} }
fun syncScripts() {
runCatching {
scriptingFolder = context.scriptManager.getScriptsFolder()
context.scriptManager.sync()
scriptModules = context.modDatabase.getScripts()
}.onFailure {
context.log.error("Failed to sync scripts", it)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
refreshing = true refreshing = true
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
syncScripts() reloadDispatcher.dispatch()
refreshing = false refreshing = false
} }
} }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
refreshing = true refreshing = true
syncScripts() coroutineScope.launch(Dispatchers.IO) {
coroutineScope.launch { reloadDispatcher.dispatch()
delay(300)
refreshing = false refreshing = false
} }
}) })
@ -206,7 +451,7 @@ class ScriptingRoot : Routes.Route() {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
if (scriptingFolder == null) { if (scriptingFolder == null && !refreshing) {
Text( Text(
text = "No scripts folder selected", text = "No scripts folder selected",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -218,7 +463,7 @@ class ScriptingRoot : Routes.Route() {
context.config.root.scripting.moduleFolder.set(it) context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig() context.config.writeConfig()
coroutineScope.launch { coroutineScope.launch {
syncScripts() reloadDispatcher.dispatch()
} }
} }
}) { }) {
@ -295,7 +540,10 @@ class ScriptingRoot : Routes.Route() {
flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK
}) })
}) { }) {
Icon(imageVector = Icons.AutoMirrored.Default.LibraryBooks, contentDescription = "Documentation") Icon(
imageVector = Icons.AutoMirrored.Default.LibraryBooks,
contentDescription = "Documentation"
)
} }
} }
} }

View File

@ -10,7 +10,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@ -27,45 +26,74 @@ import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.ReceiversConfig
import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
class AddFriendDialog( class AddFriendDialog(
private val context: RemoteSideContext, private val context: RemoteSideContext,
private val socialRoot: SocialRoot, private val actionHandler: Actions,
) { ) {
class Actions(
val onFriendState: (friend: MessagingFriendInfo, state: Boolean) -> Unit,
val onGroupState: (group: MessagingGroupInfo, state: Boolean) -> Unit,
val getFriendState: (friend: MessagingFriendInfo) -> Boolean,
val getGroupState: (group: MessagingGroupInfo) -> Boolean,
)
private val stateCache = mutableMapOf<String, Boolean>()
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
@Composable @Composable
private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { private fun ListCardEntry(
var currentState by remember { mutableStateOf(getCurrentState()) } id: String,
bitmoji: String? = null,
name: String,
getCurrentState: () -> Boolean,
onState: (Boolean) -> Unit = {},
) {
var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) {
getCurrentState().also { stateCache[id] = it }
}
val coroutineScope = rememberCoroutineScope()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
currentState = !currentState currentState = !currentState
onState(currentState) stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) {
onState(currentState)
}
} }
.padding(4.dp), .padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
BitmojiImage(
context = this@AddFriendDialog.context,
url = bitmoji,
modifier = Modifier.padding(end = 2.dp),
size = 32,
)
Text( Text(
text = name, text = name,
fontSize = 15.sp, fontSize = 15.sp,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.onGloballyPositioned {
currentState = getCurrentState()
}
) )
Checkbox( Checkbox(
checked = currentState, checked = currentState,
onCheckedChange = { onCheckedChange = {
currentState = it currentState = it
onState(currentState) stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) {
onState(currentState)
}
} }
) )
} }
@ -122,7 +150,7 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) } var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
context.modDatabase.receiveMessagingDataCallback = { friends, groups -> context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends cachedFriends = friends
cachedGroups = groups cachedGroups = groups
timeoutJob?.cancel() timeoutJob?.cancel()
@ -138,7 +166,7 @@ class AddFriendDialog(
} }
timeoutJob = coroutineScope.launch { timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
delay(10000) delay(20000)
hasFetchError = true hasFetchError = true
} }
} }
@ -216,15 +244,11 @@ class AddFriendDialog(
items(filteredGroups.size) { items(filteredGroups.size) {
val group = filteredGroups[it] val group = filteredGroups[it]
ListCardEntry( ListCardEntry(
id = group.conversationId,
name = group.name, name = group.name,
getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } getCurrentState = { actionHandler.getGroupState(group) }
) { state -> ) { state ->
if (state) { actionHandler.onGroupState(group, state)
context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
} else {
context.modDatabase.deleteGroup(group.conversationId)
}
socialRoot.updateScopeLists()
} }
} }
@ -237,19 +261,18 @@ class AddFriendDialog(
) )
} }
items(filteredFriends.size) { items(filteredFriends.size) { index ->
val friend = filteredFriends[it] val friend = filteredFriends[index]
ListCardEntry( ListCardEntry(
id = friend.userId,
bitmoji = friend.takeIf { it.bitmojiId != null }?.let {
BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
},
name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername,
getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null } getCurrentState = { actionHandler.getFriendState(friend) }
) { state -> ) { state ->
if (state) { actionHandler.onFriendState(friend, state)
context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
} else {
context.modDatabase.deleteFriend(friend.userId)
}
socialRoot.updateScopeLists()
} }
} }
} }

View File

@ -29,10 +29,10 @@ import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.download.* import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.storage.getFriendInfo
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.Dialog
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
import okhttp3.OkHttpClient
import java.io.File import java.io.File
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
@ -45,7 +45,7 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember { mutableStateListOf<StoryData>() } val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.modDatabase.getFriendInfo(userId) } val friendInfo = remember { context.database.getFriendInfo(userId) }
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) } var selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@ -17,13 +17,19 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.FriendStreaks
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.AlertDialogs import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.Dialog
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ -32,10 +38,10 @@ class ManageScope: Routes.Route() {
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) { private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
when (scope) { when (scope) {
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) SocialScope.FRIEND -> context.database.deleteFriend(id)
SocialScope.GROUP -> context.modDatabase.deleteGroup(id) SocialScope.GROUP -> context.database.deleteGroup(id)
} }
context.modDatabase.executeAsync { context.database.executeAsync {
coroutineScope.launch { coroutineScope.launch {
routes.navController.popBackStack() routes.navController.popBackStack()
} }
@ -79,48 +85,98 @@ class ManageScope: Routes.Route() {
val id = navBackStackEntry.arguments?.getString("id")!! val id = navBackStackEntry.arguments?.getString("id")!!
Column( Column(
modifier = Modifier.verticalScroll(rememberScrollState()) modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
) { ) {
when (scope) { var hasScope by remember {
SocialScope.FRIEND -> Friend(id) mutableStateOf(null as Boolean?)
SocialScope.GROUP -> Group(id)
} }
when (scope) {
Spacer(modifier = Modifier.height(16.dp)) SocialScope.FRIEND -> {
var streaks by remember { mutableStateOf(null as FriendStreaks?) }
val rules = context.modDatabase.getRules(id) val friend by rememberAsyncMutableState(null) {
context.database.getFriendInfo(id)?.also {
SectionTitle(translation["rules_title"]) streaks = context.database.getFriendStreaks(id)
}.also {
ContentCard { hasScope = it != null
MessagingRuleType.entries.forEach { ruleType -> }
var ruleEnabled by remember {
mutableStateOf(rules.any { it.key == ruleType.key })
} }
friend?.let {
val ruleState = context.config.root.rules.getRuleState(ruleType) Friend(id, it, streaks)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(all = 4.dp)
) {
Text(
text = if (ruleType.listMode && ruleState != null) {
context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"]
} else context.translation["rules.properties.${ruleType.key}.name"],
modifier = Modifier
.weight(1f)
.padding(start = 5.dp, end = 5.dp)
)
Switch(checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.modDatabase.setRule(id, ruleType.key, it)
ruleEnabled = it
}
)
} }
} }
SocialScope.GROUP -> {
val group by rememberAsyncMutableState(null) {
context.database.getGroupInfo(id).also {
hasScope = it != null
}
}
group?.let {
Group(it)
}
}
}
if (hasScope == true) {
RulesCard(id)
}
if (hasScope == false) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = translation["not_found"],
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun RulesCard(
id: String
) {
Spacer(modifier = Modifier.height(16.dp))
val rules = rememberAsyncMutableStateList(listOf()) {
context.database.getRules(id)
}
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember(rules.size) {
mutableStateOf(rules.any { it.key == ruleType.key })
}
val ruleState = context.config.root.rules.getRuleState(ruleType)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(all = 4.dp)
) {
Text(
text = if (ruleType.listMode && ruleState != null) {
context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"]
} else context.translation["rules.properties.${ruleType.key}.name"],
modifier = Modifier
.weight(1f)
.padding(start = 5.dp, end = 5.dp)
)
Switch(checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.database.setRule(id, ruleType.key, it)
ruleEnabled = it
}
)
}
} }
} }
} }
@ -185,17 +241,11 @@ class ManageScope: Routes.Route() {
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
@Composable @Composable
private fun Friend(id: String) { private fun Friend(
//fetch the friend from the database id: String,
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { friend: MessagingFriendInfo,
Text(text = translation["not_found"]) streaks: FriendStreaks?
return ) {
}
val streaks = remember {
context.modDatabase.getFriendStreaks(id)
}
Column( Column(
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)
@ -275,7 +325,7 @@ class ManageScope: Routes.Route() {
modifier = Modifier.padding(end = 10.dp) modifier = Modifier.padding(end = 10.dp)
) )
Switch(checked = shouldNotify, onCheckedChange = { Switch(checked = shouldNotify, onCheckedChange = {
context.modDatabase.setFriendStreaksNotify(id, it) context.database.setFriendStreaksNotify(id, it)
shouldNotify = it shouldNotify = it
}) })
} }
@ -286,7 +336,9 @@ class ManageScope: Routes.Route() {
if (context.config.root.experimental.e2eEncryption.globalState == true) { if (context.config.root.experimental.e2eEncryption.globalState == true) {
SectionTitle(translation["e2ee_title"]) SectionTitle(translation["e2ee_title"])
var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))} var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
context.e2eeImplementation.friendKeyExists(friend.userId)
}
var importDialog by remember { mutableStateOf(false) } var importDialog by remember { mutableStateOf(false) }
if (importDialog) { if (importDialog) {
@ -302,8 +354,11 @@ class ManageScope: Routes.Route() {
return@runCatching return@runCatching
} }
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) context.coroutineScope.launch {
context.longToast("Successfully imported key") context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
}
hasSecretKey = true hasSecretKey = true
}.onFailure { }.onFailure {
context.longToast("Failed to import key: ${it.message}") context.longToast("Failed to import key: ${it.message}")
@ -320,20 +375,22 @@ class ManageScope: Routes.Route() {
) { ) {
if (hasSecretKey) { if (hasSecretKey) {
OutlinedButton(onClick = { OutlinedButton(onClick = {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton) context.coroutineScope.launch {
//TODO: fingerprint auth val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
context.activity!!.startActivity(Intent.createChooser(Intent().apply { //TODO: fingerprint auth
action = Intent.ACTION_SEND context.activity!!.startActivity(Intent.createChooser(Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey) action = Intent.ACTION_SEND
type = "text/plain" putExtra(Intent.EXTRA_TEXT, secretKey)
}, "").apply { type = "text/plain"
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( }, "").apply {
Intent().apply { putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
putExtra(Intent.EXTRA_TEXT, secretKey) Intent().apply {
putExtra(Intent.EXTRA_SUBJECT, secretKey) putExtra(Intent.EXTRA_TEXT, secretKey)
}) putExtra(Intent.EXTRA_SUBJECT, secretKey)
) })
}) )
})
}
}) { }) {
Text( Text(
text = "Export Base64", text = "Export Base64",
@ -355,13 +412,7 @@ class ManageScope: Routes.Route() {
} }
@Composable @Composable
private fun Group(id: String) { private fun Group(group: MessagingGroupInfo) {
//fetch the group from the database
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
Text(text = translation["not_found"])
return
}
Column( Column(
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)

View File

@ -478,12 +478,14 @@ class MessagingPreview: Routes.Route() {
isBridgeConnected = context.hasMessagingBridge() isBridgeConnected = context.hasMessagingBridge()
if (isBridgeConnected) { if (isBridgeConnected) {
onMessagingBridgeReady(scope, id) withContext(Dispatchers.IO) {
} else { onMessagingBridgeReady(scope, id)
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
} }
} else {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
}
withTimeout(10000) { withTimeout(10000) {
while (!context.hasMessagingBridge()) { while (!context.hasMessagingBridge()) {
delay(100) delay(100)

View File

@ -22,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.R import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
@ -38,15 +38,32 @@ class SocialRoot : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList()) private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList()) private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
fun updateScopeLists() { private fun updateScopeLists() {
context.coroutineScope.launch(Dispatchers.IO) { context.coroutineScope.launch {
friendList = context.modDatabase.getFriends(descOrder = true) friendList = context.database.getFriends(descOrder = true)
groupList = context.modDatabase.getGroups() groupList = context.database.getGroups()
} }
} }
private val addFriendDialog by lazy { private val addFriendDialog by lazy {
AddFriendDialog(context, this) AddFriendDialog(context, AddFriendDialog.Actions(
onFriendState = { friend, state ->
if (state) {
context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
} else {
context.database.deleteFriend(friend.userId)
}
},
onGroupState = { group, state ->
if (state) {
context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
} else {
context.database.deleteGroup(group.conversationId)
}
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
))
} }
@Composable @Composable
@ -82,7 +99,7 @@ class SocialRoot : Routes.Route() {
SocialScope.FRIEND -> friendList[index].userId SocialScope.FRIEND -> friendList[index].userId
} }
Card( ElevatedCard(
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)
.fillMaxWidth() .fillMaxWidth()
@ -119,12 +136,8 @@ class SocialRoot : Routes.Route() {
SocialScope.FRIEND -> { SocialScope.FRIEND -> {
val friend = friendList[index] val friend = friendList[index]
var streaks by remember { mutableStateOf(friend.streaks) } val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
context.database.getFriendStreaks(friend.userId)
LaunchedEffect(friend.userId) {
withContext(Dispatchers.IO) {
streaks = context.modDatabase.getFriendStreaks(friend.userId)
}
} }
BitmojiImage( BitmojiImage(
@ -204,6 +217,11 @@ class SocialRoot : Routes.Route() {
addFriendDialog.Content { addFriendDialog.Content {
showAddFriendDialog = false showAddFriendDialog = false
} }
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View File

@ -0,0 +1,463 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import me.rhunk.snapenhance.common.data.TrackerEventType
import me.rhunk.snapenhance.common.data.TrackerRuleAction
import me.rhunk.snapenhance.common.data.TrackerRuleActionParams
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
import me.rhunk.snapenhance.common.data.TrackerScopeType
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog
@Composable
fun ActionCheckbox(
text: String,
checked: MutableState<Boolean>,
onChanged: (Boolean) -> Unit = {}
) {
Row(
modifier = Modifier.clickable {
checked.value = !checked.value
onChanged(checked.value)
},
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.size(30.dp),
checked = checked.value,
onCheckedChange = {
checked.value = it
onChanged(it)
}
)
Text(text, fontSize = 12.sp)
}
}
@Composable
fun ConditionCheckboxes(
params: TrackerRuleActionParams
) {
ActionCheckbox(text = "Only when I'm inside conversation", checked = remember { mutableStateOf(params.onlyInsideConversation) }, onChanged = { params.onlyInsideConversation = it })
ActionCheckbox(text = "Only when I'm outside conversation", checked = remember { mutableStateOf(params.onlyOutsideConversation) }, onChanged = { params.onlyOutsideConversation = it })
ActionCheckbox(text = "Only when Snapchat is active", checked = remember { mutableStateOf(params.onlyWhenAppActive) }, onChanged = { params.onlyWhenAppActive = it })
ActionCheckbox(text = "Only when Snapchat is inactive", checked = remember { mutableStateOf(params.onlyWhenAppInactive) }, onChanged = { params.onlyWhenAppInactive = it })
ActionCheckbox(text = "No notification when Snapchat is active", checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) }, onChanged = { params.noPushNotificationWhenAppActive = it })
}
class EditRule : Routes.Route() {
private val fab = mutableStateOf<@Composable (() -> Unit)?>(null)
// persistent add event state
private var currentEventType by mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key)
private var addEventActions by mutableStateOf(emptySet<TrackerRuleAction>())
private val addEventActionParams by mutableStateOf(TrackerRuleActionParams())
override val floatingActionButton: @Composable () -> Unit = {
fab.value?.invoke()
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry ->
val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull()
val events = rememberAsyncMutableStateList(defaultValue = emptyList()) {
currentRuleId?.let { ruleId ->
context.database.getTrackerEvents(ruleId)
} ?: emptyList()
}
var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) }
val scopes = rememberAsyncMutableStateList(defaultValue = emptyList()) {
currentRuleId?.let { ruleId ->
context.database.getRuleTrackerScopes(ruleId).also {
currentScopeType = if (it.isEmpty()) {
TrackerScopeType.WHITELIST
} else {
it.values.first()
}
}.map { it.key }
} ?: emptyList()
}
val ruleName = rememberAsyncMutableState(defaultValue = "", keys = arrayOf(currentRuleId)) {
currentRuleId?.let { ruleId ->
context.database.getTrackerRule(ruleId)?.name ?: "Custom Rule"
} ?: "Custom Rule"
}
LaunchedEffect(Unit) {
fab.value = {
var deleteConfirmation by remember { mutableStateOf(false) }
if (deleteConfirmation) {
AlertDialog(
onDismissRequest = { deleteConfirmation = false },
title = { Text("Delete Rule") },
text = { Text("Are you sure you want to delete this rule?") },
confirmButton = {
Button(
onClick = {
if (currentRuleId != null) {
context.database.deleteTrackerRule(currentRuleId)
}
routes.navController.popBackStack()
}
) {
Text("Delete")
}
},
dismissButton = {
Button(
onClick = { deleteConfirmation = false }
) {
Text("Cancel")
}
}
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.End
) {
ExtendedFloatingActionButton(
onClick = {
val ruleId = currentRuleId ?: context.database.newTrackerRule()
events.forEach { event ->
context.database.addOrUpdateTrackerRuleEvent(
event.id.takeIf { it > -1 },
ruleId,
event.eventType,
event.params,
event.actions
)
}
context.database.setTrackerRuleName(ruleId, ruleName.value.trim())
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
routes.navController.popBackStack()
},
text = { Text("Save Rule") },
icon = { Icon(Icons.Default.Save, contentDescription = "Save Rule") }
)
if (currentRuleId != null) {
ExtendedFloatingActionButton(
containerColor = MaterialTheme.colorScheme.error,
onClick = { deleteConfirmation = true },
text = { Text("Delete Rule") },
icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete Rule") }
)
}
}
}
}
DisposableEffect(Unit) {
onDispose { fab.value = null }
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
TextField(
value = ruleName.value,
onValueChange = {
ruleName.value = it
},
singleLine = true,
placeholder = {
Text(
"Rule Name",
fontSize = 18.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold)
)
}
item {
Column(
modifier = Modifier.fillMaxWidth(),
){
Text("Scope", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp))
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
val friendDialogActions = remember {
AddFriendDialog.Actions(
onFriendState = { friend, state ->
if (state) {
scopes.add(friend.userId)
} else {
scopes.remove(friend.userId)
}
},
onGroupState = { group, state ->
if (state) {
scopes.add(group.conversationId)
} else {
scopes.remove(group.conversationId)
}
},
getFriendState = { friend ->
friend.userId in scopes
},
getGroupState = { group ->
group.conversationId in scopes
}
)
}
Box(modifier = Modifier.clickable { scopes.clear() }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isEmpty(), onClick = null)
Text("All Friends/Groups")
}
}
Box(modifier = Modifier.clickable {
currentScopeType = TrackerScopeType.BLACKLIST
addFriendDialog = AddFriendDialog(
context,
friendDialogActions
)
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.BLACKLIST, onClick = null)
Text("Blacklist" + if (currentScopeType == TrackerScopeType.BLACKLIST && scopes.isNotEmpty()) " (" + scopes.size.toString() + ")" else "")
}
}
Box(modifier = Modifier.clickable {
currentScopeType = TrackerScopeType.WHITELIST
addFriendDialog = AddFriendDialog(
context,
friendDialogActions
)
}) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.WHITELIST, onClick = null)
Text("Whitelist" + if (currentScopeType == TrackerScopeType.WHITELIST && scopes.isNotEmpty()) " (" + scopes.size.toString() + ")" else "")
}
}
addFriendDialog?.Content {
addFriendDialog = null
}
}
var addEventDialog by remember { mutableStateOf(false) }
val showDropdown = remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp))
IconButton(onClick = { addEventDialog = true }, modifier = Modifier.padding(8.dp)) {
Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(32.dp))
}
}
if (addEventDialog) {
AlertDialog(
onDismissRequest = { addEventDialog = false },
title = { Text("Add Event", fontSize = 20.sp, fontWeight = FontWeight.Bold) },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Type", fontSize = 14.sp, fontWeight = FontWeight.Bold)
ExposedDropdownMenuBox(expanded = showDropdown.value, onExpandedChange = { showDropdown.value = it }) {
ElevatedButton(
onClick = { showDropdown.value = true },
modifier = Modifier.menuAnchor()
) {
Text(currentEventType, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
DropdownMenu(expanded = showDropdown.value, onDismissRequest = { showDropdown.value = false }) {
TrackerEventType.entries.forEach { eventType ->
DropdownMenuItem(onClick = {
currentEventType = eventType.key
showDropdown.value = false
}, text = {
Text(eventType.key)
})
}
}
}
}
Text("Triggers", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp))
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
TrackerRuleAction.entries.forEach { action ->
ActionCheckbox(action.name, checked = remember { mutableStateOf(addEventActions.contains(action)) }) {
if (it) {
addEventActions += action
} else {
addEventActions -= action
}
}
}
}
Text("Conditions", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp))
ConditionCheckboxes(addEventActionParams)
}
},
confirmButton = {
Button(
onClick = {
events.add(0, TrackerRuleEvent(-1, true, currentEventType, addEventActionParams.copy(), addEventActions.toList()))
addEventDialog = false
}
) {
Text("Add")
}
}
)
}
}
item {
if (events.isEmpty()) {
Text("No events", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier
.padding(10.dp)
.fillMaxWidth(), textAlign = TextAlign.Center)
}
}
items(events) { event ->
var expanded by remember { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.padding(4.dp),
onClick = { expanded = !expanded }
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f, fill = false),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Column {
Text(event.eventType, lineHeight = 20.sp, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Text(text = event.actions.joinToString(", ") { it.name }, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 14.sp)
}
}
OutlinedIconButton(
onClick = {
if (event.id > -1) {
context.database.deleteTrackerRuleEvent(event.id)
}
events.remove(event)
}
) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
if (expanded) {
Column(
modifier = Modifier.padding(10.dp)
) {
ConditionCheckboxes(event.params)
}
}
}
}
}
item {
Spacer(modifier = Modifier.height(140.dp))
}
}
}
}

View File

@ -0,0 +1,470 @@
package me.rhunk.snapenhance.ui.manager.pages.tracker
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.TrackerEventType
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
import java.text.DateFormat
@OptIn(ExperimentalFoundationApi::class)
class FriendTrackerManagerRoot : Routes.Route() {
enum class FilterType {
CONVERSATION, USERNAME, EVENT
}
private val titles = listOf("Logs", "Rules")
private var currentPage by mutableIntStateOf(0)
override val floatingActionButton: @Composable () -> Unit = {
if (currentPage == 1) {
ExtendedFloatingActionButton(
icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") },
expanded = true,
text = { Text("Add Rule") },
onClick = { routes.editRule.navigate() }
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LogsTab() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf<TrackerLog>() }
var lastTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var filterType by remember { mutableStateOf(FilterType.USERNAME) }
var filter by remember { mutableStateOf("") }
var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
suspend fun loadNewLogs() {
withContext(Dispatchers.IO) {
logs.addAll(context.messageLogger.getLogs(lastTimestamp, filter = {
when (filterType) {
FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true)
FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup)
FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true)
}
}).apply {
lastTimestamp = minOfOrNull { it.timestamp } ?: lastTimestamp
})
}
}
suspend fun resetAndLoadLogs() {
logs.clear()
lastTimestamp = Long.MAX_VALUE
loadNewLogs()
}
Column(
modifier = Modifier.fillMaxSize()
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
var showAutoComplete by remember { mutableStateOf(false) }
var dropDownExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = showAutoComplete,
onExpandedChange = { showAutoComplete = it },
) {
TextField(
value = filter,
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
.padding(8.dp),
onValueChange = {
filter = it
coroutineScope.launch {
searchTimeoutJob?.cancel()
searchTimeoutJob = coroutineScope.launch {
delay(200)
showAutoComplete = true
resetAndLoadLogs()
}
}
},
placeholder = { Text("Search") },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent
),
maxLines = 1,
leadingIcon = {
ExposedDropdownMenuBox(
expanded = dropDownExpanded,
onExpandedChange = { dropDownExpanded = it },
) {
ElevatedCard(
modifier = Modifier
.menuAnchor()
.padding(2.dp)
) {
Text(filterType.name, modifier = Modifier.padding(8.dp))
}
DropdownMenu(expanded = dropDownExpanded, onDismissRequest = {
dropDownExpanded = false
}) {
FilterType.entries.forEach { type ->
DropdownMenuItem(onClick = {
filter = ""
filterType = type
dropDownExpanded = false
coroutineScope.launch {
resetAndLoadLogs()
}
}, text = {
Text(type.name)
})
}
}
}
},
trailingIcon = {
if (filter != "") {
IconButton(onClick = {
filter = ""
coroutineScope.launch {
resetAndLoadLogs()
}
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
DropdownMenu(
expanded = showAutoComplete,
onDismissRequest = {
showAutoComplete = false
},
properties = PopupProperties(focusable = false),
) {
val suggestedEntries = remember(filter) {
mutableStateListOf<String>()
}
LaunchedEffect(filter) {
launch(Dispatchers.IO) {
suggestedEntries.addAll(when (filterType) {
FilterType.USERNAME -> context.messageLogger.findUsername(filter)
FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter)
FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key }
}.take(5))
}
}
suggestedEntries.forEach { entry ->
DropdownMenuItem(onClick = {
filter = entry
coroutineScope.launch {
resetAndLoadLogs()
}
showAutoComplete = false
}, text = {
Text(entry)
})
}
}
},
)
}
}
LazyColumn(
modifier = Modifier.weight(1f)
) {
item {
if (logs.isEmpty()) {
Text("No logs found", modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light)
}
}
items(logs, key = { it.userId + it.id }) { log ->
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
databaseFriend = context.database.getFriendInfo(log.userId)
}
}
BitmojiImage(
modifier = Modifier.padding(10.dp),
size = 70,
context = context,
url = databaseFriend?.takeIf { it.bitmojiId != null }?.let {
BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
},
)
Column(
modifier = Modifier
.weight(1f),
) {
Text(databaseFriend?.displayName?.let {
"$it (${log.username})"
} ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${log.eventType} in ${log.conversationTitle}", fontSize = 15.sp, fontWeight = FontWeight.Light, lineHeight = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
DateFormat.getDateTimeInstance().format(log.timestamp),
fontSize = 10.sp,
fontWeight = FontWeight.Light,
lineHeight = 15.sp,
)
}
OutlinedIconButton(
onClick = {
context.messageLogger.deleteTrackerLog(log.id)
logs.remove(log)
}
) {
Icon(Icons.Default.DeleteOutline, contentDescription = "Delete")
}
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(lastTimestamp) {
loadNewLogs()
}
}
}
}
}
@Composable
private fun ConfigRulesTab() {
val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc()
}
Column(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f)
) {
item {
if (rules.isEmpty()) {
Text("No rules found", modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light)
}
}
items(rules, key = { it.id }) { rule ->
val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
context.database.getTrackerRule(rule.id)?.name ?: "(empty)"
}
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getTrackerEvents(rule.id).size
}
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getRuleTrackerScopes(rule.id).size
}
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
context.database.getTrackerRule(rule.id)?.enabled ?: false
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
}
.padding(5.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(ruleName, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(buildString {
append(eventCount)
append(" events")
if (scopeCount > 0) {
append(", ")
append(scopeCount)
append(" scopes")
}
}, fontSize = 13.sp, fontWeight = FontWeight.Light)
}
Row(
modifier = Modifier.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleTrackerScopes(rule.id, limit = 10).mapNotNull {
context.database.getFriendInfo(it.key)?.let { friend ->
friend.selfieId to friend.bitmojiId
}
}.take(3)
}
Row {
scopesBitmoji.forEachIndexed { index, friend ->
Box(
modifier = Modifier
.offset(x = (-index * 20).dp + (scopesBitmoji.size * 20).dp - 20.dp)
) {
BitmojiImage(
size = 50,
modifier = Modifier
.border(
BorderStroke(1.dp, Color.White),
CircleShape
)
.background(Color.White, CircleShape)
.clip(CircleShape),
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
)
}
}
}
Box(modifier = Modifier
.padding(start = 5.dp, end = 5.dp)
.height(50.dp)
.width(1.dp)
.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
shape = RoundedCornerShape(5.dp)
)
)
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
currentPage = pagerState.currentPage
Column {
TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
Modifier.pagerTabIndicatorOffset(
pagerState = pagerState,
tabPositions = tabPositions
)
)
}) {
titles.forEachIndexed { index, title ->
Tab(
selected = pagerState.currentPage == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = {
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
HorizontalPager(
modifier = Modifier.weight(1f),
state = pagerState
) { page ->
when (page) {
0 -> LogsTab()
1 -> ConfigRulesTab()
}
}
}
}
}

View File

@ -29,7 +29,7 @@ fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size
imageLoader = context.imageLoader imageLoader = context.imageLoader
), ),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Inside,
modifier = Modifier modifier = Modifier
.requiredWidthIn(min = 0.dp, max = size.dp) .requiredWidthIn(min = 0.dp, max = size.dp)
.height(size.dp) .height(size.dp)

View File

@ -33,6 +33,8 @@
"home_logs": "Logs", "home_logs": "Logs",
"logger_history": "Logger History", "logger_history": "Logger History",
"logged_stories": "Logged Stories", "logged_stories": "Logged Stories",
"friend_tracker": "Friend Tracker",
"edit_rule": "Edit Rule",
"social": "Social", "social": "Social",
"manage_scope": "Manage Scope", "manage_scope": "Manage Scope",
"messaging_preview": "Preview", "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": { "spoof": {
"name": "Spoof", "name": "Spoof",
"description": "Spoof various information about you", "description": "Spoof various information about you",
@ -1039,6 +1027,20 @@
"description": "Disables the anonymization of logs" "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": { "options": {

View File

@ -1,16 +1,24 @@
package me.rhunk.snapenhance.common.action 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( enum class EnumAction(
val key: String, val key: String,
val icon: ImageVector,
val exitOnFinish: Boolean = false, val exitOnFinish: Boolean = false,
) { ) {
EXPORT_CHAT_MESSAGES("export_chat_messages"), EXPORT_CHAT_MESSAGES("export_chat_messages", Icons.AutoMirrored.Default.Chat),
EXPORT_MEMORIES("export_memories"), EXPORT_MEMORIES("export_memories", Icons.Default.Image),
BULK_MESSAGING_ACTION("bulk_messaging_action"), BULK_MESSAGING_ACTION("bulk_messaging_action", Icons.Default.DeleteOutline),
MANAGE_FRIEND_LIST("manage_friend_list"), CLEAN_CACHE("clean_snapchat_cache", Icons.Default.CleaningServices, exitOnFinish = true),
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true); MANAGE_FRIEND_LIST("manage_friend_list", Icons.Default.PersonOutline);
companion object { companion object {
const val ACTION_PARAMETER = "se_action" 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.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.rhunk.snapenhance.bridge.logger.LoggerInterface import me.rhunk.snapenhance.bridge.logger.LoggerInterface
import me.rhunk.snapenhance.common.data.StoryData 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.SQLiteDatabaseHelper
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getIntOrNull
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
class LoggedMessageEdit(
val timestamp: Long,
val messageText: String
)
class LoggedMessage( class LoggedMessage(
val messageId: Long, val messageId: Long,
val timestamp: Long, val timestamp: Long,
val messageData: ByteArray val messageData: ByteArray,
) )
class TrackerLog( class TrackerLog(
val id: Int,
val timestamp: Long, val timestamp: Long,
val conversationId: String, val conversationId: String,
val conversationTitle: String?, val conversationTitle: String?,
@ -37,6 +46,7 @@ class LoggerWrapper(
private var _database: SQLiteDatabase? = null private var _database: SQLiteDatabase? = null
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val gson by lazy { GsonBuilder().create() }
private val database get() = synchronized(this) { private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run { _database?.takeIf { it.isOpen } ?: run {
@ -50,6 +60,14 @@ class LoggerWrapper(
"message_id BIGINT", "message_id BIGINT",
"message_data BLOB" "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( "stories" to listOf(
"id INTEGER PRIMARY KEY", "id INTEGER PRIMARY KEY",
"added_timestamp BIGINT", "added_timestamp BIGINT",
@ -111,18 +129,66 @@ class LoggerWrapper(
} }
override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) { 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 hasMessage = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())).use {
val state = cursor.moveToFirst() it.moveToFirst()
cursor.close() it.count > 0
if (state) return }
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 { runBlocking {
withContext(coroutineScope.coroutineContext) { withContext(coroutineScope.coroutineContext) {
database.insert("messages", null, ContentValues().apply { runCatching {
put("added_timestamp", System.currentTimeMillis()) val messageObject = gson.fromJson(
put("conversation_id", conversationId) serializedMessage.toString(Charsets.UTF_8),
put("message_id", messageId) JsonObject::class.java
put("message_data", serializedMessage) )
}) 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 { maxAge?.let {
val maxTime = System.currentTimeMillis() - it val maxTime = System.currentTimeMillis() - it
database.execSQL("DELETE FROM messages WHERE added_timestamp < ?", arrayOf(maxTime.toString())) 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())) database.execSQL("DELETE FROM stories WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
} ?: run { } ?: run {
database.execSQL("DELETE FROM messages") database.execSQL("DELETE FROM messages")
database.execSQL("DELETE FROM chat_edits")
database.execSQL("DELETE FROM stories") database.execSQL("DELETE FROM stories")
} }
} }
@ -157,6 +225,7 @@ class LoggerWrapper(
override fun deleteMessage(conversationId: String, messageId: Long) { override fun deleteMessage(conversationId: String, messageId: Long) {
coroutineScope.launch { coroutineScope.launch {
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) 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( fun getLogs(
lastTimestamp: Long, lastTimestamp: Long,
filter: ((TrackerLog) -> Boolean)? = null filter: ((TrackerLog) -> Boolean)? = null
@ -215,6 +290,7 @@ class LoggerWrapper(
val logs = mutableListOf<TrackerLog>() val logs = mutableListOf<TrackerLog>()
while (it.moveToNext() && logs.size < 50) { while (it.moveToNext() && logs.size < 50) {
val log = TrackerLog( val log = TrackerLog(
id = it.getIntOrNull("id") ?: continue,
timestamp = it.getLongOrNull("timestamp") ?: continue, timestamp = it.getLongOrNull("timestamp") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue, conversationId = it.getStringOrNull("conversation_id") ?: continue,
conversationTitle = it.getStringOrNull("conversation_title"), 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( fun fetchMessages(
conversationId: String, conversationId: String,
fromTimestamp: Long, fromTimestamp: Long,

View File

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

View File

@ -1,14 +1,12 @@
package me.rhunk.snapenhance.common.config.impl 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.ConfigContainer
import me.rhunk.snapenhance.common.config.FeatureNotice import me.rhunk.snapenhance.common.config.FeatureNotice
class Experimental : ConfigContainer() { 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) { class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) {
val showFirstCreatedUsername = boolean("show_first_created_username") val showFirstCreatedUsername = boolean("show_first_created_username")
val bypassCameraRollLimit = boolean("bypass_camera_roll_limit") val bypassCameraRollLimit = boolean("bypass_camera_roll_limit")
@ -34,9 +32,8 @@ class Experimental : ConfigContainer() {
val lockOnResume = boolean("lock_on_resume", defaultValue = true) val lockOnResume = boolean("lock_on_resume", defaultValue = true)
} }
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
val sessionEvents = container("session_events", SessionEventsConfig()) { requireRestart(); nativeHooks() } val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() } val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() }
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } 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 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.ConfigContainer
import me.rhunk.snapenhance.common.config.FeatureNotice import me.rhunk.snapenhance.common.config.FeatureNotice
class RootConfig : ConfigContainer() { class RootConfig : ConfigContainer() {
val downloader = container("downloader", DownloaderConfig()) { icon = "Download"} val downloader = container("downloader", DownloaderConfig()) { icon = Icons.Default.Download }
val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"} val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = Icons.Default.RemoveRedEye }
val messaging = container("messaging", MessagingTweaks()) { icon = "Send" } val messaging = container("messaging", MessagingTweaks()) { icon = Icons.AutoMirrored.Default.Send }
val global = container("global", Global()) { icon = "MiscellaneousServices" } val global = container("global", Global()) { icon = Icons.Default.MiscellaneousServices }
val rules = container("rules", Rules()) { icon = "Rule" } val rules = container("rules", Rules()) { icon = Icons.AutoMirrored.Default.Rule }
val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() } val camera = container("camera", Camera()) { icon = Icons.Default.Camera; requireRestart() }
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = Icons.Default.Alarm }
val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices( val experimental = container("experimental", Experimental()) { icon = Icons.Default.Science; addNotices(
FeatureNotice.UNSTABLE) } 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_DELETED("message_deleted"),
MESSAGE_SAVED("message_saved"), MESSAGE_SAVED("message_saved"),
MESSAGE_UNSAVED("message_unsaved"), MESSAGE_UNSAVED("message_unsaved"),
MESSAGE_EDITED("message_edited"),
MESSAGE_REACTION_ADD("message_reaction_add"), MESSAGE_REACTION_ADD("message_reaction_add"),
MESSAGE_REACTION_REMOVE("message_reaction_remove"), MESSAGE_REACTION_REMOVE("message_reaction_remove"),
SNAP_OPENED("snap_opened"), SNAP_OPENED("snap_opened"),
@ -44,70 +45,6 @@ enum class SessionEventType(
SNAP_SCREEN_RECORD("snap_screen_record"), 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( enum class TrackerEventType(
val key: String val key: String
) { ) {
@ -126,6 +63,7 @@ enum class TrackerEventType(
MESSAGE_DELETED("message_deleted"), MESSAGE_DELETED("message_deleted"),
MESSAGE_SAVED("message_saved"), MESSAGE_SAVED("message_saved"),
MESSAGE_UNSAVED("message_unsaved"), MESSAGE_UNSAVED("message_unsaved"),
MESSAGE_EDITED("message_edited"),
MESSAGE_REACTION_ADD("message_reaction_add"), MESSAGE_REACTION_ADD("message_reaction_add"),
MESSAGE_REACTION_REMOVE("message_reaction_remove"), MESSAGE_REACTION_REMOVE("message_reaction_remove"),
SNAP_OPENED("snap_opened"), SNAP_OPENED("snap_opened"),
@ -134,3 +72,104 @@ enum class TrackerEventType(
SNAP_SCREENSHOT("snap_screenshot"), SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"), 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 android.widget.Toast
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
import me.rhunk.snapenhance.common.scripting.bindings.BindingsContext 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.JavaInterfaces
import me.rhunk.snapenhance.common.scripting.impl.Networking
import me.rhunk.snapenhance.common.scripting.ktx.contextScope import me.rhunk.snapenhance.common.scripting.ktx.contextScope
import me.rhunk.snapenhance.common.scripting.ktx.putFunction import me.rhunk.snapenhance.common.scripting.ktx.putFunction
import me.rhunk.snapenhance.common.scripting.ktx.scriptable 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.ScriptableObject
import org.mozilla.javascript.Undefined import org.mozilla.javascript.Undefined
import org.mozilla.javascript.Wrapper import org.mozilla.javascript.Wrapper
import java.io.Reader
import java.lang.reflect.Modifier import java.lang.reflect.Modifier
import kotlin.reflect.KClass import kotlin.reflect.KClass
class JSModule( class JSModule(
val scriptRuntime: ScriptRuntime, private val scriptRuntime: ScriptRuntime,
val moduleInfo: ModuleInfo, val moduleInfo: ModuleInfo,
val content: String, private val reader: Reader,
) { ) {
private val moduleBindings = mutableMapOf<String, AbstractBinding>() private val moduleBindings = mutableMapOf<String, AbstractBinding>()
private lateinit var moduleObject: ScriptableObject 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( registerBindings(
JavaInterfaces(), JavaInterfaces(),
InterfaceManager(), InterfaceManager(),
@ -186,7 +199,7 @@ class JSModule(
} }
contextScope(shouldOptimize = true) { 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 { private fun argsToString(args: Array<out Any?>?): String {
return args?.joinToString(" ") { return args?.joinToString(" ") {
when (it) { 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() else -> it.toString()
} }
} ?: "null" } ?: "null"

View File

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

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) { return when (type) {
BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1" BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1"
BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle" 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"
} }
} }
} }

View File

@ -17,15 +17,15 @@ import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.common.config.ModConfig import me.rhunk.snapenhance.common.config.ModConfig
import me.rhunk.snapenhance.core.action.ActionManager
import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.bridge.BridgeClient
import me.rhunk.snapenhance.core.bridge.loadFromBridge import me.rhunk.snapenhance.core.bridge.loadFromBridge
import me.rhunk.snapenhance.core.database.DatabaseAccess import me.rhunk.snapenhance.core.database.DatabaseAccess
import me.rhunk.snapenhance.core.event.EventBus import me.rhunk.snapenhance.core.event.EventBus
import me.rhunk.snapenhance.core.event.EventDispatcher import me.rhunk.snapenhance.core.event.EventDispatcher
import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.logger.CoreLogger
import me.rhunk.snapenhance.core.action.ActionManager
import me.rhunk.snapenhance.core.features.FeatureManager import me.rhunk.snapenhance.core.features.FeatureManager
import me.rhunk.snapenhance.core.logger.CoreLogger
import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge
import me.rhunk.snapenhance.core.messaging.MessageSender import me.rhunk.snapenhance.core.messaging.MessageSender
import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime

View File

@ -36,11 +36,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.common.data.FriendLinkType
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.FriendInfo
import me.rhunk.snapenhance.common.messaging.MessagingConstraints import me.rhunk.snapenhance.common.messaging.MessagingConstraints
import me.rhunk.snapenhance.common.messaging.MessagingTask import me.rhunk.snapenhance.common.messaging.MessagingTask
import me.rhunk.snapenhance.common.messaging.MessagingTaskType import me.rhunk.snapenhance.common.messaging.MessagingTaskType
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.core.action.AbstractAction import me.rhunk.snapenhance.core.action.AbstractAction
@ -62,6 +64,8 @@ class BulkMessagingAction : AbstractAction() {
ADDED_TIMESTAMP, ADDED_TIMESTAMP,
SNAP_SCORE, SNAP_SCORE,
STREAK_LENGTH, STREAK_LENGTH,
MOST_MESSAGES_SENT,
MOST_RECENT_MESSAGE,
} }
enum class Filter { enum class Filter {
@ -172,6 +176,12 @@ class BulkMessagingAction : AbstractAction() {
} }
} }
private fun getDMLastMessage(userId: String?): ConversationMessage? {
return context.database.getConversationLinkFromUserId(userId ?: return null)?.clientConversationId?.let {
context.database.getMessagesFromConversationId(it, 1)
}?.firstOrNull()
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
private fun BulkMessagingDialog() { private fun BulkMessagingDialog() {
@ -198,6 +208,12 @@ class BulkMessagingAction : AbstractAction() {
SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp } SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp }
SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore } SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore }
SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength } SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength }
SortBy.MOST_MESSAGES_SENT -> newFriends.sortByDescending {
getDMLastMessage(it.userId)?.serverMessageId ?: 0
}
SortBy.MOST_RECENT_MESSAGE -> newFriends.sortByDescending {
getDMLastMessage(it.userId)?.creationTimestamp
}
} }
if (sortReverseOrder) newFriends.reverse() if (sortReverseOrder) newFriends.reverse()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -288,7 +304,7 @@ class BulkMessagingAction : AbstractAction() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(3.dp)
) { ) {
stickyHeader { stickyHeader {
Row( Row(
@ -398,10 +414,14 @@ class BulkMessagingAction : AbstractAction() {
horizontalArrangement = Arrangement.spacedBy(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
){ ){
Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1) Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp)
Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1) Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp)
} }
val userInfo = remember(friendInfo) { val lastMessage by rememberAsyncMutableState(defaultValue = null) {
getDMLastMessage(friendInfo.userId)
}
val userInfo = remember(friendInfo, lastMessage) {
buildString { buildString {
append("Relationship: ") append("Relationship: ")
append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"]) append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"])
@ -414,9 +434,13 @@ class BulkMessagingAction : AbstractAction() {
friendInfo.streakLength.takeIf { it > 0 }?.let { friendInfo.streakLength.takeIf { it > 0 }?.let {
append("\nStreaks length: $it") append("\nStreaks length: $it")
} }
lastMessage?.let {
append("\nSent messages: ${it.serverMessageId}")
append("\nLast message date: ${DateFormat.getDateTimeInstance().format(Date(it.creationTimestamp))}")
}
} }
} }
Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 16.sp, overflow = TextOverflow.Ellipsis) Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 12.sp, overflow = TextOverflow.Ellipsis)
} }
Checkbox( Checkbox(

View File

@ -20,7 +20,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.common.data.FriendLinkType
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
import me.rhunk.snapenhance.core.action.AbstractAction import me.rhunk.snapenhance.core.action.AbstractAction

View File

@ -10,6 +10,7 @@ import me.rhunk.snapenhance.core.features.impl.downloader.ProfilePictureDownload
import me.rhunk.snapenhance.core.features.impl.experiments.* import me.rhunk.snapenhance.core.features.impl.experiments.*
import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.global.*
import me.rhunk.snapenhance.core.features.impl.messaging.* import me.rhunk.snapenhance.core.features.impl.messaging.*
import me.rhunk.snapenhance.core.features.impl.spying.FriendTracker
import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier
import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
@ -112,7 +113,7 @@ class FeatureManager(
OperaViewerParamsOverride(), OperaViewerParamsOverride(),
StealthModeIndicator(), StealthModeIndicator(),
DisablePermissionRequests(), DisablePermissionRequests(),
SessionEvents(), FriendTracker(),
DefaultVolumeControls(), DefaultVolumeControls(),
CallRecorder(), CallRecorder(),
DisableMemoriesSnapFeed(), DisableMemoriesSnapFeed(),

View File

@ -2,8 +2,8 @@ package me.rhunk.snapenhance.core.features.impl
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.MixerStoryType import me.rhunk.snapenhance.common.data.MixerStoryType
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.Feature

View File

@ -9,9 +9,6 @@ import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.FeatureLoadParams
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.Hooker
import java.nio.ByteBuffer
class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")

View File

@ -1,8 +1,11 @@
package me.rhunk.snapenhance.core.features.impl.experiments package me.rhunk.snapenhance.core.features.impl.spying
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.data.* import me.rhunk.snapenhance.common.data.*
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
@ -13,14 +16,22 @@ import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.hook.hookConstructor import me.rhunk.snapenhance.core.util.hook.hookConstructor
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID
import me.rhunk.snapenhance.nativelib.NativeLib import me.rhunk.snapenhance.nativelib.NativeLib
import java.lang.reflect.Method import java.lang.reflect.Method
import java.nio.ByteBuffer import java.nio.ByteBuffer
class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.INIT_SYNC) { class FriendTracker : Feature("Friend Tracker", loadParams = FeatureLoadParams.INIT_SYNC) {
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state) private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val tracker by lazy { context.bridgeClient.getTracker() } private val tracker by lazy { context.bridgeClient.getTracker() }
private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(NotificationChannel(
"friend_tracker",
"Friend Tracker",
NotificationManager.IMPORTANCE_DEFAULT
))
} }
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? { private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
return runCatching { return runCatching {
@ -32,14 +43,14 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
}.getOrNull() }.getOrNull()
} }
private fun isInConversation(conversationId: String) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId private fun isInConversation(conversationId: String?) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId
private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) { private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) {
context.androidContext.getSystemService(NotificationManager::class.java).notify( notificationManager.notify(
id, id,
Notification.Builder( Notification.Builder(
context.androidContext, context.androidContext,
"general_group_generic_push_noisy_generic_push_B~LVSD2" "friend_tracker"
) )
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true) .setAutoCancel(true)
@ -62,6 +73,49 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
context.log.verbose("volatile event\n$protoReader") context.log.verbose("volatile event\n$protoReader")
} }
private fun dispatchEvents(
eventType: TrackerEventType,
conversationId: String,
userId: String,
extras: String = ""
) {
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
context.log.verbose("$authorName $eventType in $conversationName")
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) ->
if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach
if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach
if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach
if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach
context.log.verbose("dispatching $action for $eventType in $conversationName")
when (action) {
TrackerRuleAction.PUSH_NOTIFICATION -> {
if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach
sendInfoNotification(text = "$authorName $eventType in $conversationName")
}
TrackerRuleAction.IN_APP_NOTIFICATION -> context.inAppOverlay.showStatusToast(
icon = Icons.Default.Info,
text = "$authorName $eventType in $conversationName"
)
TrackerRuleAction.LOG -> context.bridgeClient.getMessageLogger().logTrackerEvent(
conversationId,
conversationName,
context.database.getConversationType(conversationId) == 1,
authorName,
userId,
eventType.key,
extras
)
else -> {}
}
}
}
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) { private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState") context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState")
@ -75,40 +129,11 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
else -> null else -> null
} ?: return } ?: return
val feedEntry = context.database.getFeedEntryByConversationId(conversationId) dispatchEvents(eventType, conversationId, userId)
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
context.log.verbose("$authorName $eventType in $conversationName")
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.apply {
if (hasFlags(TrackerFlags.APP_IS_ACTIVE) && context.isMainActivityPaused) return
if (hasFlags(TrackerFlags.APP_IS_INACTIVE) && !context.isMainActivityPaused) return
if (hasFlags(TrackerFlags.IS_IN_CONVERSATION) && !isInConversation(conversationId)) return
if (hasFlags(TrackerFlags.NOTIFY)) sendInfoNotification(text = "$authorName $eventType in $conversationName")
if (hasFlags(TrackerFlags.LOG)) {
context.bridgeClient.getMessageLogger().logTrackerEvent(
conversationId,
conversationName,
context.database.getConversationType(conversationId) == 1,
authorName,
userId,
eventType.key,
""
)
}
}
} }
private fun onConversationMessagingEvent(event: SessionEvent) { private fun onConversationMessagingEvent(event: SessionEvent) {
context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}") context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}")
val isConversationGroup = context.database.getConversationType(event.conversationId) == 1
val authorName = context.database.getFriendInfo(event.authorUserId)?.mutableUsername ?: "Unknown"
val conversationName = context.database.getFeedEntryByConversationId(event.conversationId)?.feedDisplayName ?: "DMs"
val conversationMessage by lazy {
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
}
val eventType = when(event.type) { val eventType = when(event.type) {
SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ
@ -117,6 +142,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
SessionEventType.MESSAGE_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE SessionEventType.MESSAGE_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE
SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
SessionEventType.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
@ -125,36 +151,19 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
else -> return else -> return
} }
val messageEvents = arrayOf( val conversationMessage by lazy {
TrackerEventType.MESSAGE_READ, (event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
TrackerEventType.MESSAGE_DELETED,
TrackerEventType.MESSAGE_REACTION_ADD,
TrackerEventType.MESSAGE_REACTION_REMOVE,
TrackerEventType.MESSAGE_SAVED,
TrackerEventType.MESSAGE_UNSAVED
)
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(event.conversationId, event.authorUserId) }?.apply {
if (messageEvents.contains(eventType) && conversationMessage?.senderId == context.database.myUserId) return
if (hasFlags(TrackerFlags.APP_IS_ACTIVE) && context.isMainActivityPaused) return
if (hasFlags(TrackerFlags.APP_IS_INACTIVE) && !context.isMainActivityPaused) return
if (hasFlags(TrackerFlags.IS_IN_CONVERSATION) && !isInConversation(event.conversationId)) return
if (hasFlags(TrackerFlags.NOTIFY)) sendInfoNotification(text = "$authorName $eventType in $conversationName")
if (hasFlags(TrackerFlags.LOG)) {
context.bridgeClient.getMessageLogger().logTrackerEvent(
event.conversationId,
conversationName,
isConversationGroup,
authorName,
event.authorUserId,
eventType.key,
messageEvents.takeIf { it.contains(eventType) }?.let {
conversationMessage?.contentType?.let { ContentType.fromId(it) } ?.name
} ?: ""
)
}
} }
dispatchEvents(eventType, event.conversationId, event.authorUserId, extras = conversationMessage?.takeIf {
eventType == TrackerEventType.MESSAGE_READ ||
eventType == TrackerEventType.MESSAGE_REACTION_ADD ||
eventType == TrackerEventType.MESSAGE_REACTION_REMOVE ||
eventType == TrackerEventType.MESSAGE_DELETED ||
eventType == TrackerEventType.MESSAGE_SAVED ||
eventType == TrackerEventType.MESSAGE_UNSAVED ||
eventType == TrackerEventType.MESSAGE_EDITED
}?.contentType?.let { ContentType.fromId(it).name } ?: "")
} }
private fun handlePresenceEvent(protoReader: ProtoReader) { private fun handlePresenceEvent(protoReader: ProtoReader) {
@ -210,6 +219,21 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
} }
} }
protoReader.followPath(13, 1, 4) {
val serverMessageId = getVarInt(1) ?: return@followPath
val senderId = getByteArray(2, 1) ?: return@followPath
val conversationId = getByteArray(3, 1, 1, 1) ?: return@followPath
onConversationMessagingEvent(
SessionMessageEvent(
SessionEventType.MESSAGE_EDITED,
SnapUUID(conversationId).toString(),
SnapUUID(senderId).toString(),
serverMessageId
)
)
}
protoReader.followPath(6, 2) { protoReader.followPath(6, 2) {
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
@ -282,7 +306,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
} }
override fun init() { override fun init() {
val sessionEventsConfig = context.config.experimental.sessionEvents val sessionEventsConfig = context.config.friendTracker
if (sessionEventsConfig.globalState != true) return if (sessionEventsConfig.globalState != true) return
if (sessionEventsConfig.allowRunningInBackground.get()) { if (sessionEventsConfig.allowRunningInBackground.get()) {
@ -300,7 +324,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
} }
} }
if (sessionEventsConfig.captureDuplexEvents.get()) { if (sessionEventsConfig.recordMessagingEvents.get()) {
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply { val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
hook("onReceive", HookStage.BEFORE) { param -> hook("onReceive", HookStage.BEFORE) { param ->
param.setResult(null) param.setResult(null)

View File

@ -119,8 +119,10 @@ class MessageLogger : Feature("MessageLogger",
if (!isMessageDeleted) { if (!isMessageDeleted) {
if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe
if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe if (event.message.messageMetadata?.isEdited != true) {
fetchedMessages.add(uniqueMessageIdentifier) if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe
fetchedMessages.add(uniqueMessageIdentifier)
}
threadPool.execute { threadPool.execute {
try { try {

View File

@ -19,12 +19,10 @@ abstract class AbstractWrapper(
inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) { inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
operator fun getValue(obj: Any, property: KProperty<*>): T? { operator fun getValue(obj: Any, property: KProperty<*>): T? {
val value = runCatching { XposedHelpers.getObjectField(instance, fieldName) }.getOrNull() return runCatching {
return if (mapper != null) { val value = XposedHelpers.getObjectField(instance, fieldName)
mapper.invoke(value) mapper?.invoke(value) ?: value as? T
} else { }.getOrNull()
value as? T
}
} }
operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) { operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) {

View File

@ -1,10 +1,10 @@
package me.rhunk.snapenhance.mapper.impl package me.rhunk.snapenhance.mapper.impl
import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload
import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.AbstractClassMapper
import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getClassName
import me.rhunk.snapenhance.mapper.ext.getStaticConstructor import me.rhunk.snapenhance.mapper.ext.getStaticConstructor
import me.rhunk.snapenhance.mapper.ext.isFinal import me.rhunk.snapenhance.mapper.ext.isFinal
import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload
class BCryptClassMapper : AbstractClassMapper("BCryptClass") { class BCryptClassMapper : AbstractClassMapper("BCryptClass") {
val classReference = classReference("class") val classReference = classReference("class")

View File

@ -1,14 +1,14 @@
package me.rhunk.snapenhance.mapper.impl package me.rhunk.snapenhance.mapper.impl
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.AbstractClassMapper
import me.rhunk.snapenhance.mapper.ext.findConstString import me.rhunk.snapenhance.mapper.ext.findConstString
import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getClassName
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
import me.rhunk.snapenhance.mapper.ext.isEnum import me.rhunk.snapenhance.mapper.ext.isEnum
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import java.lang.reflect.Modifier import java.lang.reflect.Modifier
class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") { class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") {

View File

@ -1,11 +1,11 @@
package me.rhunk.snapenhance.mapper.impl package me.rhunk.snapenhance.mapper.impl
import com.android.tools.smali.dexlib2.AccessFlags
import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.AbstractClassMapper
import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getClassName
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
import me.rhunk.snapenhance.mapper.ext.isAbstract import me.rhunk.snapenhance.mapper.ext.isAbstract
import me.rhunk.snapenhance.mapper.ext.isEnum import me.rhunk.snapenhance.mapper.ext.isEnum
import com.android.tools.smali.dexlib2.AccessFlags
class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") { class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") {
val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider") val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider")

View File

@ -1,11 +1,11 @@
package me.rhunk.snapenhance.mapper.impl package me.rhunk.snapenhance.mapper.impl
import com.android.tools.smali.dexlib2.iface.Method import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.AbstractClassMapper
import me.rhunk.snapenhance.mapper.ext.findConstString import me.rhunk.snapenhance.mapper.ext.findConstString
import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getClassName
import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
class OperaViewerParamsMapper : AbstractClassMapper("OperaViewerParams") { class OperaViewerParamsMapper : AbstractClassMapper("OperaViewerParams") {
val classReference = classReference("class") val classReference = classReference("class")