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:exported="true" />
<receiver android:name=".messaging.StreaksReminder" />
<receiver android:name=".StreaksReminder" />
<provider
android:name="androidx.core.content.FileProvider"

View File

@ -2,6 +2,8 @@ package me.rhunk.snapenhance
import android.util.Log
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.logger.AbstractLogger
import me.rhunk.snapenhance.common.logger.LogChannel
@ -70,33 +72,39 @@ class LogReader(
}
fun incrementLineCount() {
randomAccessFile.seek(randomAccessFile.length())
startLineIndexes.add(randomAccessFile.filePointer + 1)
lineCount++
synchronized(randomAccessFile) {
randomAccessFile.seek(randomAccessFile.length())
startLineIndexes.add(randomAccessFile.filePointer + 1)
lineCount++
}
}
private fun queryLineCount(): Int {
randomAccessFile.seek(0)
var lineCount = 0
var lastPointer: Long
var line: String?
synchronized(randomAccessFile) {
randomAccessFile.seek(0)
var lineCount = 0
var lastPointer: Long
var line: String?
while (randomAccessFile.also {
lastPointer = it.filePointer
}.readLine().also { line = it } != null) {
if (line?.startsWith('|') == true) {
lineCount++
startLineIndexes.add(lastPointer + 1)
while (randomAccessFile.also {
lastPointer = it.filePointer
}.readLine().also { line = it } != null) {
if (line?.startsWith('|') == true) {
lineCount++
startLineIndexes.add(lastPointer + 1)
}
}
}
return lineCount
return lineCount
}
}
private fun getLine(index: Int): String? {
if (index <= 0 || index > lineCount) return null
randomAccessFile.seek(startLineIndexes[index])
return readLogLine()?.toString()
synchronized(randomAccessFile) {
randomAccessFile.seek(startLineIndexes[index])
return readLogLine()?.toString()
}
}
fun getLogLine(index: Int): LogLine? {
@ -109,7 +117,6 @@ class LogManager(
private val remoteSideContext: RemoteSideContext
): AbstractLogger(LogChannel.MANAGER) {
companion object {
private const val TAG = "SnapEnhanceManager"
private val LOG_LIFETIME = 24.hours
}
@ -118,13 +125,13 @@ class LogManager(
var lineAddListener = { _: LogLine -> }
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 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() }})") }
init {
fun init() {
if (!logFolder.exists()) {
logFolder.mkdirs()
}
@ -153,7 +160,9 @@ class LogManager(
tag = tag,
message = anonymizedMessage
)
logFile.appendText("|$line\n", Charsets.UTF_8)
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
logFile?.appendText("|$line\n", Charsets.UTF_8)
}
lineAddListener(line)
Log.println(logLevel.priority, tag, anonymizedMessage)
}.onFailure {
@ -172,8 +181,8 @@ class LogManager(
val currentTime = System.currentTimeMillis()
logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
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() {
@ -201,7 +210,7 @@ class LogManager(
zipOutputStream.close()
}
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also {
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also {
lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) }
}

View File

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

View File

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

View File

@ -1,29 +1,29 @@
package me.rhunk.snapenhance
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.TrackerRule
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
import me.rhunk.snapenhance.common.util.toSerialized
import me.rhunk.snapenhance.storage.getRuleTrackerScopes
import me.rhunk.snapenhance.storage.getTrackerEvents
class RemoteTracker(
private val context: RemoteSideContext
): TrackerInterface.Stub() {
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)
}*/
}
fun init() {}
override fun getTrackedEvents(eventType: String): String? {
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)
}
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.NotificationChannel
@ -10,11 +10,10 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.toBitmap
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.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 kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
@ -56,8 +55,8 @@ class StreaksReminder(
PendingIntent.FLAG_IMMUTABLE)
)
val notifyFriendList = remoteSideContext.modDatabase.getFriends()
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) }
val notifyFriendList = remoteSideContext.database.getFriends()
.associateBy { remoteSideContext.database.getFriendStreaks(it.userId) }
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
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.download.DownloadProcessor
import me.rhunk.snapenhance.download.FFMpegProcessor
import me.rhunk.snapenhance.storage.*
import me.rhunk.snapenhance.task.Task
import me.rhunk.snapenhance.task.TaskType
import java.io.File
@ -47,7 +48,7 @@ class BridgeService : Service() {
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
runCatching {
val modDatabase = remoteSideContext.modDatabase
val modDatabase = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && modDatabase.getFriendInfo(id) == null) return
@ -194,24 +195,24 @@ class BridgeService : Service() {
}
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> {
return remoteSideContext.modDatabase.getRuleIds(type)
return remoteSideContext.database.getRuleIds(type)
}
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) {
syncCallback = callback
measureTimeMillis {
remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId ->
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
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)
}
}.also {
@ -229,7 +230,7 @@ class BridgeService : Service() {
friends: List<String>
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.modDatabase.receiveMessagingDataCallback(
remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
)

View File

@ -1,6 +1,5 @@
package me.rhunk.snapenhance.download
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Bitmap
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>()
fun addFile(file: DocumentFile) {
files.add(file)
lastModifiedMap[file.uri] = file.lastModified()
synchronized(lastModifiedMap) {
files.add(file)
lastModifiedMap[file.uri] = file.lastModified()
}
}
fun start() {
coroutineScope.launch(Dispatchers.IO) {
while (true) {
files.forEach { file ->
val lastModified = lastModifiedMap[file.uri] ?: return@forEach
runCatching {
val newLastModified = file.lastModified()
if (newLastModified > lastModified) {
lastModifiedMap[file.uri] = newLastModified
onReload(file)
synchronized(lastModifiedMap) {
files.forEach { file ->
val lastModified = lastModifiedMap[file.uri] ?: return@forEach
runCatching {
val newLastModified = file.lastModified()
if (newLastModified > lastModified) {
lastModifiedMap[file.uri] = newLastModified
onReload(file)
}
}.onFailure {
it.printStackTrace()
}
}.onFailure {
it.printStackTrace()
}
}
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.ManagerIPC
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.InputStream
import kotlin.system.exitProcess
@ -28,6 +32,10 @@ class RemoteScriptManager(
scripting = this@RemoteScriptManager
}
private val okHttpClient by lazy {
OkHttpClient.Builder().build()
}
private var autoReloadListener: AutoReloadListener? = null
private val autoReloadHandler by lazy {
AutoReloadHandler(context.coroutineScope) {
@ -49,6 +57,7 @@ class RemoteScriptManager(
private val ipcListeners = IPCListeners()
fun sync() {
cachedModuleInfo.clear()
getScriptFileNames().forEach { name ->
runCatching {
getScriptInputStream(name) { stream ->
@ -63,7 +72,7 @@ class RemoteScriptManager(
}
}
context.modDatabase.syncScripts(cachedModuleInfo.values.toList())
context.database.syncScripts(cachedModuleInfo.values.toList())
}
fun init() {
@ -77,7 +86,11 @@ class RemoteScriptManager(
sync()
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) {
val content = getScriptContent(path) ?: return
runtime.load(path, content)
if (context.config.root.scripting.autoReload.getNullable() != null) {
autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
}
runtime.load(path, content)
}
fun unloadScript(scriptPath: String) {
@ -119,10 +132,38 @@ class RemoteScriptManager(
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> {
return runCatching {
getScriptFileNames().filter {
context.modDatabase.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
context.database.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
}
}.onFailure {
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)
primaryRoutes.forEach { route ->
NavigationBarItem(
alwaysShowLabel = false,
alwaysShowLabel = true,
icon = {
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
},
@ -88,7 +88,7 @@ class Navigation(
softWrap = false,
fontSize = 12.sp,
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,

View File

@ -15,7 +15,6 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavGraphBuilder
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.TasksRoot
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.MessagingPreview
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(
@ -55,6 +56,7 @@ class Routes(
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).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 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.graphics.Color
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import com.google.gson.JsonParser
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.MediaDownloadSource
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.longHashCode
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.MessageDecoder
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 java.nio.ByteBuffer
import java.text.DateFormat
import java.util.UUID
import kotlin.math.absoluteValue
@ -114,7 +121,7 @@ class LoggerHistoryRoot : Routes.Route() {
) {
Row(
modifier = Modifier
.padding(4.dp)
.padding(8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
@ -123,7 +130,7 @@ class LoggerHistoryRoot : Routes.Route() {
LaunchedEffect(Unit, message) {
runCatching {
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
fun ContentHeader() {
@ -141,6 +148,26 @@ class LoggerHistoryRoot : Routes.Route() {
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()
}
}
@ -209,9 +236,9 @@ class LoggerHistoryRoot : Routes.Route() {
) {
fun formatConversationId(conversationId: String?): String? {
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)
} ?: 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))
} ?: conversationId
}
@ -225,13 +252,8 @@ class LoggerHistoryRoot : Routes.Route() {
.fillMaxWidth()
)
val conversations = remember { mutableStateListOf<String>() }
LaunchedEffect(Unit) {
conversations.clear()
withContext(Dispatchers.IO) {
conversations.addAll(loggerWrapper.getAllConversations())
}
val conversations by rememberAsyncMutableState(defaultValue = emptyList()) {
loggerWrapper.getAllConversations().toMutableList()
}
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.draw.clip
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.documentfile.provider.DocumentFile
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.MediaDownloadSource
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.download.DownloadProcessor
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) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) {
taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile ->
IconButton(onClick = {
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile.uri, documentFile.type)
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)
if (taskSelection.size == 1) {
val selectionExists by rememberAsyncMutableState(defaultValue = false) {
taskSelection.firstOrNull()?.second?.exists() == true
}
if (selectionExists) {
taskSelection.firstOrNull()?.second?.let { documentFile ->
IconButton(onClick = {
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile.uri, documentFile.type)
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 }) {
IconButton(onClick = {
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
}) {
Icon(Icons.Filled.Merge, contentDescription = "Merge")
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
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()) {
Text(translation["remove_selected_tasks_title"])
Row (
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable {
alsoDeleteFiles = !alsoDeleteFiles
},
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth()
.clickable {
alsoDeleteFiles = !alsoDeleteFiles
},
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -207,19 +223,22 @@ class TasksRoot : Routes.Route() {
Button(
onClick = {
showConfirmDialog = false
if (taskSelection.isNotEmpty()) {
taskSelection.forEach { (task, documentFile) ->
context.taskManager.removeTask(task)
recentTasks.remove(task)
if (alsoDeleteFiles) {
documentFile?.delete()
coroutineScope.launch(Dispatchers.IO) {
context.taskManager.removeTask(task)
if (alsoDeleteFiles) {
documentFile?.delete()
}
}
recentTasks.remove(task)
}
activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
taskSelection.clear()
} else {
context.taskManager.clearAllTasks()
coroutineScope.launch(Dispatchers.IO) {
context.taskManager.clearAllTasks()
}
recentTasks.clear()
activeTasks.forEach {
runCatching {
@ -255,16 +274,17 @@ class TasksRoot : Routes.Route() {
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
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) {
launch(Dispatchers.IO) {
documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch)
isDocumentFileReadable = documentFile?.canRead() ?: false
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
}
val listener = remember { PendingTaskListener(
onStateChange = {
taskStatus = it
@ -285,19 +305,21 @@ class TasksRoot : Routes.Route() {
}
}
OutlinedCard(modifier = modifier.clickable {
if (isSelected) {
taskSelection.removeIf { it.first == task }
return@clickable
OutlinedCard(modifier = modifier
.clickable {
if (isSelected) {
taskSelection.removeIf { it.first == task }
return@clickable
}
taskSelection.add(task to documentFile)
}
taskSelection.add(task to documentFile)
}.let {
if (isSelected) {
it
.border(2.dp, MaterialTheme.colorScheme.primary)
.clip(MaterialTheme.shapes.medium)
} else it
}) {
.let {
if (isSelected) {
it
.border(2.dp, MaterialTheme.colorScheme.primary)
.clip(MaterialTheme.shapes.medium)
} else it
}) {
Row(
modifier = Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically
@ -305,13 +327,12 @@ class TasksRoot : Routes.Route() {
Column(
modifier = Modifier.padding(end = 15.dp)
) {
documentFile?.let { file ->
val mimeType = file.type ?: ""
documentFile?.let {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
}
} ?: 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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.util.*
@OptIn(ExperimentalMaterial3Api::class)
class FeaturesRoot : Routes.Route() {
private val alertDialogs by lazy { AlertDialogs(context.translation) }
@ -313,7 +311,7 @@ class FeaturesRoot : Routes.Route() {
FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
)
Card(
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.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),
horizontalArrangement = Arrangement.SpaceBetween
) {
property.key.params.icon?.let { iconName ->
//TODO: find a better way to load icons
val icon: ImageVector? = remember(iconName) {
runCatching {
val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt")
val method = cl.declaredMethods.first()
method.invoke(null, Icons.Filled) as ImageVector
}.getOrNull()
}
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(start = 10.dp)
)
}
property.key.params.icon?.let { icon ->
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(start = 10.dp)
)
}
Column(

View File

@ -31,6 +31,7 @@ import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.LogReader
import me.rhunk.snapenhance.common.logger.LogChannel
@ -64,8 +65,12 @@ class HomeLogs : Routes.Route() {
modifier = Modifier.align(Alignment.CenterVertically)
) {
DropdownMenuItem(onClick = {
context.log.clearLogs()
navigate()
context.coroutineScope.launch {
context.log.clearLogs()
}
routes.navController.navigate(routeInfo.id) {
popUpTo(routeInfo.id) { inclusive = true }
}
showDropDown = false
}, text = {
Text(translation["clear_logs_button"])
@ -148,63 +153,73 @@ class HomeLogs : Routes.Route() {
}
}
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) }
Box(modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
coroutineScope.launch {
clipboardManager.setText(AnnotatedString(logLine.message))
}
},
onTap = {
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
logLine?.let { line ->
Box(modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
coroutineScope.launch {
clipboardManager.setText(
AnnotatedString(
line.message
)
)
}
},
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 = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag,
modifier = Modifier.padding(start = 4.dp),
fontWeight = FontWeight.Light,
text = line.message.trimIndent(),
fontSize = 10.sp,
)
Text(
text = logLine.dateTime,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
fontSize = 10.sp
maxLines = if (expand) Int.MAX_VALUE else 6,
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
softWrap = !expand,
)
}
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.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
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.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.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.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.ColorFilter
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.text.font.Font
import androidx.compose.ui.text.font.FontFamily
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.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.R
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.data.InstallationSummary
import me.rhunk.snapenhance.ui.manager.data.Updater
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
class HomeRoot : Routes.Route() {
companion object {
@ -46,61 +51,33 @@ class HomeRoot : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
private fun launchActionIntent(action: EnumAction) {
val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key)
context.androidContext.startActivity(intent)
}
@Composable
private fun SummaryCards(installationSummary: InstallationSummary) {
val summaryInfo = remember {
mapOf(
"Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"),
"Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"),
"Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"),
"Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"),
"Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"),
"Device" to installationSummary.platformInfo.device,
"Android Version" to installationSummary.platformInfo.androidVersion,
"System ABI" to installationSummary.platformInfo.systemAbi
)
}
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
)
}
private val cards by lazy {
mapOf(
("Friend Tracker" to Icons.Default.PersonSearch) to {
routes.friendTracker.navigateReset()
},
("Logger History" to Icons.Default.History) to {
routes.loggerHistory.navigateReset()
},
).toMutableMap().apply {
EnumAction.entries.forEach { action ->
this[context.translation["actions.${action.key}.name"] to action.icon] = {
launchActionIntent(action)
}
}
}
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity !!)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
IconButton(onClick = {
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 = {
val avenirNextFontFamily = remember {
FontFamily(
@ -121,26 +128,17 @@ class HomeRoot : Routes.Route() {
)
}
var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.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 = 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,
fontFamily = avenirNextFontFamily,
modifier = Modifier.align(Alignment.CenterHorizontally),
@ -153,50 +151,48 @@ class HomeRoot : Routes.Route() {
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(
horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally),
modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(
15.dp, Alignment.CenterHorizontally
), modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp)
) {
Icon(
LinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp).clickable {
context.activity?.startActivity(
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
}
)
}
dataArray = 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, 58, 115, 112, 116, 116, 104
)
)
Icon(
LinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp).clickable {
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
}
)
}
dataArray = 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
)
)
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) {
@ -209,7 +205,7 @@ class HomeRoot : Routes.Route() {
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
){
) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -223,17 +219,16 @@ class HomeRoot : Routes.Route() {
fontWeight = FontWeight.Bold,
)
Text(
fontSize = 12.sp,
text = translation.format("update_content", "version" to (latestUpdate?.versionName ?: "unknown")),
lineHeight = 20.sp
fontSize = 12.sp, text = translation.format(
"update_content",
"version" to (latestUpdate?.versionName ?: "unknown")
), lineHeight = 20.sp
)
}
Button(onClick = {
context.activity?.startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(latestUpdate?.releaseUrl)
}
)
context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(latestUpdate?.releaseUrl)
})
}, modifier = Modifier.height(40.dp)) {
Text(text = translation["update_button"])
}
@ -241,38 +236,93 @@ class HomeRoot : Routes.Route() {
}
}
val coroutineScope = rememberCoroutineScope()
var installationSummary by remember { mutableStateOf(null as InstallationSummary?) }
var showQuickActionsMenu by remember { mutableStateOf(false) }
fun updateInstallationSummary(scope: CoroutineScope) {
scope.launch(Dispatchers.IO) {
runCatching {
installationSummary = context.installationSummary
}.onFailure {
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 30.dp, top = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Quick Actions", fontSize = 20.sp, modifier = Modifier.weight(1f))
Box {
IconButton(
onClick = { showQuickActionsMenu = !showQuickActionsMenu },
) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
runCatching {
if (!BuildConfig.DEBUG) {
latestUpdate = Updater.checkForLatestRelease()
DropdownMenu(
expanded = showQuickActionsMenu,
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.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.action.EnumAction
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.setup.Requirements
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
@ -165,13 +167,11 @@ class HomeSettings : Routes.Route() {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
var storedMessagesCount by remember { mutableIntStateOf(0) }
var storedStoriesCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
}
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredMessageCount()
}
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredStoriesCount()
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
@ -273,7 +273,9 @@ class HomeSettings : Routes.Route() {
}
Button(onClick = {
runCatching {
selectedFileType.resolve(context.androidContext).delete()
context.coroutineScope.launch {
selectedFileType.resolve(context.androidContext).delete()
}
}.onFailure {
context.log.error("Failed to clear file", it)
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.sp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
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.InterfaceManager
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.util.ActivityLauncherHelper
import me.rhunk.snapenhance.ui.util.Dialog
import me.rhunk.snapenhance.ui.util.chooseFolder
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
@ -35,19 +45,213 @@ import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
class ScriptingRoot : Routes.Route() {
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
override val init: () -> Unit = {
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
fun ModuleItem(script: ModuleInfo) {
var enabled by remember {
mutableStateOf(context.modDatabase.isScriptEnabled(script.name))
var enabled by rememberAsyncMutableState(defaultValue = false) {
context.database.isScriptEnabled(script.name)
}
var openSettings by remember {
mutableStateOf(false)
}
var openActions by remember {
mutableStateOf(false)
}
Card(
modifier = Modifier
@ -59,43 +263,64 @@ class ScriptingRoot : Routes.Route() {
modifier = Modifier
.fillMaxWidth()
.clickable {
if (!enabled) return@clickable
openSettings = !openSettings
}
.padding(8.dp),
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(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
) {
Text(text = script.displayName ?: script.name, fontSize = 20.sp,)
Text(text = script.description ?: "No description", fontSize = 14.sp,)
Text(text = script.displayName ?: script.name, fontSize = 20.sp)
Text(text = script.description ?: "No description", fontSize = 14.sp)
}
IconButton(onClick = { openSettings = !openSettings }) {
Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",)
IconButton(onClick = {
openActions = !openActions
}) {
Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
}
Switch(
checked = enabled,
onCheckedChange = { isChecked ->
context.modDatabase.setScriptEnabled(script.name, isChecked)
enabled = isChecked
runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!!
context.scriptManager.unloadScript(modulePath)
if (isChecked) {
context.scriptManager.loadScript(modulePath)
context.scriptManager.runtime.getModuleByName(script.name)
?.callFunction("module.onSnapEnhanceLoad")
context.shortToast("Loaded script ${script.name}")
} else {
context.shortToast("Unloaded script ${script.name}")
}
}.onFailure { throwable ->
enabled = !isChecked
("Failed to ${if (isChecked) "enable" else "disable"} script").let {
context.log.error(it, throwable)
context.shortToast(it)
openSettings = false
context.coroutineScope.launch(Dispatchers.IO) {
runCatching {
val modulePath = context.scriptManager.getModulePath(script.name)!!
context.scriptManager.unloadScript(modulePath)
if (isChecked) {
context.scriptManager.loadScript(modulePath)
context.scriptManager.runtime.getModuleByName(script.name)
?.callFunction("module.onSnapEnhanceLoad")
context.shortToast("Loaded script ${script.name}")
} else {
context.shortToast("Unloaded script ${script.name}")
}
context.database.setScriptEnabled(script.name, isChecked)
withContext(Dispatchers.Main) {
enabled = isChecked
}
}.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)
}
}
if (openActions) {
ModuleActions(script) { openActions = false }
}
}
override val floatingActionButton: @Composable () -> Unit = {
var showImportDialog by remember {
mutableStateOf(false)
}
if (showImportDialog) {
ImportRemoteScript {
showImportDialog = false
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End,
) {
ExtendedFloatingActionButton(
onClick = {
showImportDialog = true
},
icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
text = {
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 = "Open Scripts Folder")
},
@ -144,8 +387,9 @@ class ScriptingRoot : Routes.Route() {
@Composable
fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember {
val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
val settingsInterface = remember {
val module =
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
}
@ -155,43 +399,44 @@ class ScriptingRoot : Routes.Route() {
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp)
)
} else {
} else {
ScriptInterface(interfaceBuilder = settingsInterface)
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) }
var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) }
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) {
context.scriptManager.getScriptsFolder()
}
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
updateDispatcher = reloadDispatcher
) {
context.scriptManager.sync()
context.database.getScripts()
}
val coroutineScope = rememberCoroutineScope()
var refreshing by remember {
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) {
refreshing = true
withContext(Dispatchers.IO) {
syncScripts()
reloadDispatcher.dispatch()
refreshing = false
}
}
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
refreshing = true
syncScripts()
coroutineScope.launch {
delay(300)
coroutineScope.launch(Dispatchers.IO) {
reloadDispatcher.dispatch()
refreshing = false
}
})
@ -206,7 +451,7 @@ class ScriptingRoot : Routes.Route() {
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
if (scriptingFolder == null) {
if (scriptingFolder == null && !refreshing) {
Text(
text = "No scripts folder selected",
style = MaterialTheme.typography.bodySmall,
@ -218,7 +463,7 @@ class ScriptingRoot : Routes.Route() {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch {
syncScripts()
reloadDispatcher.dispatch()
}
}
}) {
@ -295,7 +540,10 @@ class ScriptingRoot : Routes.Route() {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.data.MessagingFriendInfo
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.ui.util.coil.BitmojiImage
class AddFriendDialog(
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")}
@Composable
private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) {
var currentState by remember { mutableStateOf(getCurrentState()) }
private fun ListCardEntry(
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(
modifier = Modifier
.fillMaxWidth()
.clickable {
currentState = !currentState
onState(currentState)
stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) {
onState(currentState)
}
}
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
BitmojiImage(
context = this@AddFriendDialog.context,
url = bitmoji,
modifier = Modifier.padding(end = 2.dp),
size = 32,
)
Text(
text = name,
fontSize = 15.sp,
modifier = Modifier
.weight(1f)
.onGloballyPositioned {
currentState = getCurrentState()
}
)
Checkbox(
checked = currentState,
onCheckedChange = {
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) }
LaunchedEffect(Unit) {
context.modDatabase.receiveMessagingDataCallback = { friends, groups ->
context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends
cachedGroups = groups
timeoutJob?.cancel()
@ -138,7 +166,7 @@ class AddFriendDialog(
}
timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) {
delay(10000)
delay(20000)
hasFetchError = true
}
}
@ -216,15 +244,11 @@ class AddFriendDialog(
items(filteredGroups.size) {
val group = filteredGroups[it]
ListCardEntry(
id = group.conversationId,
name = group.name,
getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null }
getCurrentState = { actionHandler.getGroupState(group) }
) { state ->
if (state) {
context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
} else {
context.modDatabase.deleteGroup(group.conversationId)
}
socialRoot.updateScopeLists()
actionHandler.onGroupState(group, state)
}
}
@ -237,19 +261,18 @@ class AddFriendDialog(
)
}
items(filteredFriends.size) {
val friend = filteredFriends[it]
items(filteredFriends.size) { index ->
val friend = filteredFriends[index]
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,
getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null }
getCurrentState = { actionHandler.getFriendState(friend) }
) { state ->
if (state) {
context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
} else {
context.modDatabase.deleteFriend(friend.userId)
}
socialRoot.updateScopeLists()
actionHandler.onFriendState(friend, state)
}
}
}

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.util.ktx.longHashCode
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.storage.getFriendInfo
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.Dialog
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
import okhttp3.OkHttpClient
import java.io.File
import java.text.DateFormat
import java.util.Date
@ -45,7 +45,7 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
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 selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@ -17,13 +17,19 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.CoroutineScope
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.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.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
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.coil.BitmojiImage
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@ -32,10 +38,10 @@ class ManageScope: Routes.Route() {
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
when (scope) {
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
SocialScope.FRIEND -> context.database.deleteFriend(id)
SocialScope.GROUP -> context.database.deleteGroup(id)
}
context.modDatabase.executeAsync {
context.database.executeAsync {
coroutineScope.launch {
routes.navController.popBackStack()
}
@ -79,48 +85,98 @@ class ManageScope: Routes.Route() {
val id = navBackStackEntry.arguments?.getString("id")!!
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
when (scope) {
SocialScope.FRIEND -> Friend(id)
SocialScope.GROUP -> Group(id)
var hasScope by remember {
mutableStateOf(null as Boolean?)
}
Spacer(modifier = Modifier.height(16.dp))
val rules = context.modDatabase.getRules(id)
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember {
mutableStateOf(rules.any { it.key == ruleType.key })
when (scope) {
SocialScope.FRIEND -> {
var streaks by remember { mutableStateOf(null as FriendStreaks?) }
val friend by rememberAsyncMutableState(null) {
context.database.getFriendInfo(id)?.also {
streaks = context.database.getFriendStreaks(id)
}.also {
hasScope = it != null
}
}
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.modDatabase.setRule(id, ruleType.key, it)
ruleEnabled = it
}
)
friend?.let {
Friend(id, it, streaks)
}
}
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)
@Composable
private fun Friend(id: String) {
//fetch the friend from the database
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
Text(text = translation["not_found"])
return
}
val streaks = remember {
context.modDatabase.getFriendStreaks(id)
}
private fun Friend(
id: String,
friend: MessagingFriendInfo,
streaks: FriendStreaks?
) {
Column(
modifier = Modifier
.padding(10.dp)
@ -275,7 +325,7 @@ class ManageScope: Routes.Route() {
modifier = Modifier.padding(end = 10.dp)
)
Switch(checked = shouldNotify, onCheckedChange = {
context.modDatabase.setFriendStreaksNotify(id, it)
context.database.setFriendStreaksNotify(id, it)
shouldNotify = it
})
}
@ -286,7 +336,9 @@ class ManageScope: Routes.Route() {
if (context.config.root.experimental.e2eEncryption.globalState == true) {
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) }
if (importDialog) {
@ -302,8 +354,11 @@ class ManageScope: Routes.Route() {
return@runCatching
}
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
context.coroutineScope.launch {
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
}
hasSecretKey = true
}.onFailure {
context.longToast("Failed to import key: ${it.message}")
@ -320,20 +375,22 @@ class ManageScope: Routes.Route() {
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}
}) {
Text(
text = "Export Base64",
@ -355,13 +412,7 @@ class ManageScope: Routes.Route() {
}
@Composable
private fun Group(id: String) {
//fetch the group from the database
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
Text(text = translation["not_found"])
return
}
private fun Group(group: MessagingGroupInfo) {
Column(
modifier = Modifier
.padding(10.dp)

View File

@ -478,12 +478,14 @@ class MessagingPreview: Routes.Route() {
isBridgeConnected = context.hasMessagingBridge()
if (isBridgeConnected) {
onMessagingBridgeReady(scope, id)
} else {
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
withContext(Dispatchers.IO) {
onMessagingBridgeReady(scope, id)
}
} else {
coroutineScope.launch(Dispatchers.IO) {
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
context.androidContext.sendBroadcast(it)
}
withTimeout(10000) {
while (!context.hasMessagingBridge()) {
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.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.R
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
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.storage.*
import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
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 groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
fun updateScopeLists() {
context.coroutineScope.launch(Dispatchers.IO) {
friendList = context.modDatabase.getFriends(descOrder = true)
groupList = context.modDatabase.getGroups()
private fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
}
}
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
@ -82,7 +99,7 @@ class SocialRoot : Routes.Route() {
SocialScope.FRIEND -> friendList[index].userId
}
Card(
ElevatedCard(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
@ -119,12 +136,8 @@ class SocialRoot : Routes.Route() {
SocialScope.FRIEND -> {
val friend = friendList[index]
var streaks by remember { mutableStateOf(friend.streaks) }
LaunchedEffect(friend.userId) {
withContext(Dispatchers.IO) {
streaks = context.modDatabase.getFriendStreaks(friend.userId)
}
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
context.database.getFriendStreaks(friend.userId)
}
BitmojiImage(
@ -204,6 +217,11 @@ class SocialRoot : Routes.Route() {
addFriendDialog.Content {
showAddFriendDialog = false
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
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
),
contentDescription = null,
contentScale = ContentScale.Crop,
contentScale = ContentScale.Inside,
modifier = Modifier
.requiredWidthIn(min = 0.dp, max = size.dp)
.height(size.dp)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.MappingsWrapper
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.loadFromBridge
import me.rhunk.snapenhance.core.database.DatabaseAccess
import me.rhunk.snapenhance.core.event.EventBus
import me.rhunk.snapenhance.core.event.EventDispatcher
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.logger.CoreLogger
import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge
import me.rhunk.snapenhance.core.messaging.MessageSender
import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime

View File

@ -36,11 +36,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.common.data.ContentType
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.messaging.MessagingConstraints
import me.rhunk.snapenhance.common.messaging.MessagingTask
import me.rhunk.snapenhance.common.messaging.MessagingTaskType
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.snap.BitmojiSelfie
import me.rhunk.snapenhance.core.action.AbstractAction
@ -62,6 +64,8 @@ class BulkMessagingAction : AbstractAction() {
ADDED_TIMESTAMP,
SNAP_SCORE,
STREAK_LENGTH,
MOST_MESSAGES_SENT,
MOST_RECENT_MESSAGE,
}
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)
@Composable
private fun BulkMessagingDialog() {
@ -198,6 +208,12 @@ class BulkMessagingAction : AbstractAction() {
SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp }
SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore }
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()
withContext(Dispatchers.Main) {
@ -288,7 +304,7 @@ class BulkMessagingAction : AbstractAction() {
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
stickyHeader {
Row(
@ -398,10 +414,14 @@ class BulkMessagingAction : AbstractAction() {
horizontalArrangement = Arrangement.spacedBy(3.dp),
verticalAlignment = Alignment.CenterVertically
){
Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1)
Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, 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, lineHeight = 10.sp)
}
val userInfo = remember(friendInfo) {
val lastMessage by rememberAsyncMutableState(defaultValue = null) {
getDMLastMessage(friendInfo.userId)
}
val userInfo = remember(friendInfo, lastMessage) {
buildString {
append("Relationship: ")
append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"])
@ -414,9 +434,13 @@ class BulkMessagingAction : AbstractAction() {
friendInfo.streakLength.takeIf { it > 0 }?.let {
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(

View File

@ -20,7 +20,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.common.action.EnumAction
import me.rhunk.snapenhance.common.data.FriendLinkType
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
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.global.*
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.MessageLogger
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
@ -112,7 +113,7 @@ class FeatureManager(
OperaViewerParamsOverride(),
StealthModeIndicator(),
DisablePermissionRequests(),
SessionEvents(),
FriendTracker(),
DefaultVolumeControls(),
CallRecorder(),
DisableMemoriesSnapFeed(),

View File

@ -2,8 +2,8 @@ package me.rhunk.snapenhance.core.features.impl
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.data.StoryData
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.core.event.events.impl.NetworkApiRequestEvent
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.FeatureLoadParams
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) {
@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.NotificationChannel
import android.app.NotificationManager
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.data.*
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.hook
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.nativelib.NativeLib
import java.lang.reflect.Method
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 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? {
return runCatching {
@ -32,14 +43,14 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
}.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) {
context.androidContext.getSystemService(NotificationManager::class.java).notify(
notificationManager.notify(
id,
Notification.Builder(
context.androidContext,
"general_group_generic_push_noisy_generic_push_B~LVSD2"
Notification.Builder(
context.androidContext,
"friend_tracker"
)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
@ -62,6 +73,49 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
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?) {
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
} ?: return
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) }?.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,
""
)
}
}
dispatchEvents(eventType, conversationId, userId)
}
private fun onConversationMessagingEvent(event: SessionEvent) {
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) {
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_SAVED -> TrackerEventType.MESSAGE_SAVED
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
SessionEventType.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
@ -125,36 +151,19 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
else -> return
}
val messageEvents = arrayOf(
TrackerEventType.MESSAGE_READ,
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
} ?: ""
)
}
val conversationMessage by lazy {
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
}
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) {
@ -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) {
val conversationId = getByteArray(3, 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() {
val sessionEventsConfig = context.config.experimental.sessionEvents
val sessionEventsConfig = context.config.friendTracker
if (sessionEventsConfig.globalState != true) return
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 {
hook("onReceive", HookStage.BEFORE) { param ->
param.setResult(null)

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
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.ext.findConstString
import me.rhunk.snapenhance.mapper.ext.getClassName
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
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
class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") {

View File

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

View File

@ -1,11 +1,11 @@
package me.rhunk.snapenhance.mapper.impl
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.ext.findConstString
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") {
val classReference = classReference("class")