From dadec3d2783da9eddbc1887090377601b6a5eb39 Mon Sep 17 00:00:00 2001
From: auth <64337177+authorisation@users.noreply.github.com>
Date: Tue, 21 May 2024 20:48:47 +0200
Subject: [PATCH] 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>
---
app/src/main/AndroidManifest.xml | 2 +-
.../kotlin/me/rhunk/snapenhance/LogManager.kt | 55 +-
.../rhunk/snapenhance/RemoteAccountStorage.kt | 6 +-
.../me/rhunk/snapenhance/RemoteSideContext.kt | 52 +-
.../me/rhunk/snapenhance/RemoteTracker.kt | 16 +-
.../{messaging => }/StreaksReminder.kt | 11 +-
.../rhunk/snapenhance/bridge/BridgeService.kt | 15 +-
.../snapenhance/download/DownloadProcessor.kt | 1 -
.../snapenhance/messaging/ModDatabase.kt | 405 ---------------
.../scripting/AutoReloadHandler.kt | 26 +-
.../scripting/RemoteScriptManager.kt | 49 +-
.../rhunk/snapenhance/storage/AppDatabase.kt | 93 ++++
.../me/rhunk/snapenhance/storage/Messaging.kt | 181 +++++++
.../rhunk/snapenhance/storage/QuickTiles.kt | 26 +
.../me/rhunk/snapenhance/storage/Scripting.kt | 73 +++
.../me/rhunk/snapenhance/storage/Tracker.kt | 197 ++++++++
.../snapenhance/ui/manager/Navigation.kt | 4 +-
.../me/rhunk/snapenhance/ui/manager/Routes.kt | 4 +-
.../manager/pages/FriendTrackerManagerRoot.kt | 330 ------------
.../ui/manager/pages/LoggerHistoryRoot.kt | 44 +-
.../snapenhance/ui/manager/pages/TasksRoot.kt | 129 +++--
.../ui/manager/pages/features/FeaturesRoot.kt | 30 +-
.../ui/manager/pages/home/HomeLogs.kt | 115 +++--
.../ui/manager/pages/home/HomeRoot.kt | 342 +++++++------
.../ui/manager/pages/home/HomeSettings.kt | 18 +-
.../manager/pages/scripting/ScriptingRoot.kt | 354 +++++++++++--
.../manager/pages/social/AddFriendDialog.kt | 79 +--
.../ui/manager/pages/social/LoggedStories.kt | 4 +-
.../ui/manager/pages/social/ManageScope.kt | 205 +++++---
.../manager/pages/social/MessagingPreview.kt | 10 +-
.../ui/manager/pages/social/SocialRoot.kt | 46 +-
.../ui/manager/pages/tracker/EditRule.kt | 463 +++++++++++++++++
.../pages/tracker/FriendTrackerManagerRoot.kt | 470 ++++++++++++++++++
.../ui/util/coil/ComposeImageHelper.kt | 2 +-
common/src/main/assets/lang/en_US.json | 30 +-
.../snapenhance/common/action/EnumAction.kt | 18 +-
.../common/bridge/wrapper/LoggerWrapper.kt | 114 ++++-
.../common/config/ConfigObjects.kt | 3 +-
.../common/config/impl/Experimental.kt | 13 +-
.../common/config/impl/FriendTrackerConfig.kt | 8 +
.../common/config/impl/RootConfig.kt | 23 +-
.../common/data/SessionEventsData.kt | 167 ++++---
.../snapenhance/common/scripting/JSModule.kt | 26 +-
.../common/scripting/ScriptRuntime.kt | 47 +-
.../common/ui/AsyncMutableState.kt | 103 ++++
.../common/util/snap/BitmojiSelfie.kt | 2 +-
.../me/rhunk/snapenhance/core/ModContext.kt | 4 +-
.../core/action/impl/BulkMessagingAction.kt | 34 +-
.../core/action/impl/ManageFriendList.kt | 1 -
.../core/features/FeatureManager.kt | 3 +-
.../core/features/impl/MixerStories.kt | 2 +-
.../downloader/ProfilePictureDownloader.kt | 3 -
.../FriendTracker.kt} | 160 +++---
.../features/impl/spying/MessageLogger.kt | 6 +-
.../core/wrapper/AbstractWrapper.kt | 10 +-
.../mapper/impl/BCryptClassMapper.kt | 2 +-
.../CompositeConfigurationProviderMapper.kt | 8 +-
.../impl/MediaQualityLevelProviderMapper.kt | 2 +-
.../mapper/impl/OperaViewerParamsMapper.kt | 4 +-
59 files changed, 3122 insertions(+), 1528 deletions(-)
rename app/src/main/kotlin/me/rhunk/snapenhance/{messaging => }/StreaksReminder.kt (94%)
delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
delete mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt
create mode 100644 common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt
create mode 100644 common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt
rename core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/{experiments/SessionEvents.kt => spying/FriendTracker.kt} (78%)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad95cd7a..51784f35 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,7 +65,7 @@
android:excludeFromRecents="true"
android:exported="true" />
-
+
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) }
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt
index 22ca263a..3083e705 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt
@@ -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 {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
index 6e3f0896..a0e5d79e 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt
@@ -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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt
index b911cd71..89b91750 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt
@@ -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>()
- 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()
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt
similarity index 94%
rename from app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt
rename to app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt
index 0b1cf985..5bd6b756 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt
@@ -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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
index 732c5797..6151cbb8 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt
@@ -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 {
- return remoteSideContext.modDatabase.getRules(uuid).map { it.key }
+ return remoteSideContext.database.getRules(uuid).map { it.key }
}
override fun getRuleIds(type: String): MutableList {
- 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
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
- remoteSideContext.modDatabase.receiveMessagingDataCallback(
+ remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable(it) },
groups.mapNotNull { toParcelable(it) }
)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
index 443c91f6..8e411610 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt
@@ -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
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
deleted file mode 100644
index ae1aec08..00000000
--- a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt
+++ /dev/null
@@ -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, groups: List) -> 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 {
- return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
- val groups = mutableListOf()
- while (cursor.moveToNext()) {
- groups.add(MessagingGroupInfo.fromCursor(cursor))
- }
- groups
- }
- }
-
- fun getFriends(descOrder: Boolean = false): List {
- 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()
- 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 {
- return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
- targetUuid
- )).use { cursor ->
- val rules = mutableListOf()
- 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 {
- return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
- val ruleIds = mutableListOf()
- while (cursor.moveToNext()) {
- ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
- }
- ruleIds
- }
- }
-
- fun getScripts(): List {
- return database.rawQuery("SELECT * FROM scripts", null).use { cursor ->
- val scripts = mutableListOf()
- 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) {
- 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 {
- val rules = mutableListOf()
-
- 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 {
- val events = mutableListOf()
- 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 {
- val events = mutableMapOf()
- 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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt
index fc224192..f9e362aa 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt
@@ -15,23 +15,27 @@ class AutoReloadHandler(
private val lastModifiedMap = mutableMapOf()
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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
index ff5b40ba..baa4d0cd 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt
@@ -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 {
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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
new file mode 100644
index 00000000..960fbb36
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt
@@ -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, groups: List) -> 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",
+ )
+ ))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
new file mode 100644
index 00000000..3156569f
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
@@ -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 {
+ return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
+ val groups = mutableListOf()
+ while (cursor.moveToNext()) {
+ groups.add(MessagingGroupInfo.fromCursor(cursor))
+ }
+ groups
+ }
+}
+
+fun AppDatabase.getFriends(descOrder: Boolean = false): List {
+ 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()
+ 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 {
+ return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf(
+ targetUuid
+ )).use { cursor ->
+ val rules = mutableListOf()
+ 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 {
+ return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor ->
+ val ruleIds = mutableListOf()
+ while (cursor.moveToNext()) {
+ ruleIds.add(cursor.getStringOrNull("targetUuid")!!)
+ }
+ ruleIds
+ }
+}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
new file mode 100644
index 00000000..facd56d2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt
@@ -0,0 +1,26 @@
+package me.rhunk.snapenhance.storage
+
+import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
+
+
+fun AppDatabase.getQuickTiles(): List {
+ return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor ->
+ val keys = mutableListOf()
+ while (cursor.moveToNext()) {
+ keys.add(cursor.getStringOrNull("key") ?: continue)
+ }
+ keys
+ }
+}
+
+fun AppDatabase.setQuickTiles(keys: List) {
+ executeAsync {
+ database.execSQL("DELETE FROM quick_tiles")
+ keys.forEachIndexed { index, key ->
+ database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf(
+ key,
+ index
+ ))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt
new file mode 100644
index 00000000..d9669fb2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt
@@ -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 {
+ return database.rawQuery("SELECT * FROM scripts ORDER BY id DESC", null).use { cursor ->
+ val scripts = mutableListOf()
+ 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) {
+ 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
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
new file mode 100644
index 00000000..c2b2cc09
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
@@ -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
+): 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 {
+ val rules = mutableListOf()
+
+ 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 {
+ val events = mutableListOf()
+ 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 {
+ val events = mutableMapOf()
+ 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) {
+ 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 {
+ val scopes = mutableMapOf()
+ 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
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
index 850f6645..d7b5058a 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt
@@ -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,
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
index 2fe375b2..3bb444ca 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt
@@ -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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt
deleted file mode 100644
index 3abf844f..00000000
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt
+++ /dev/null
@@ -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() }
- 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(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()
- }
-
- 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() }
-
- Column(
- modifier = Modifier.fillMaxSize()
- ) {
- LazyColumn(
- modifier = Modifier.weight(1f)
- ) {
- items(rules) { rule ->
- val events = remember(rule.id) {
- mutableStateListOf()
- }
-
- 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()
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
index e9062913..b6f9ffa0 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt
@@ -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() }
-
- 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 }) {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt
index ba10c86d..f7026d1e 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt
@@ -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(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
- var documentFile by remember { mutableStateOf(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 {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt
index 9e4ae2bc..53c86e26 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt
@@ -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(
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
index 0085556c..4a7e2b3f 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt
@@ -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,
- )
}
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt
index 42eb661c..8664ecd4 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt
@@ -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(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) }
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
index f1b74951..1965ce0f 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt
@@ -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}")
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt
index ab3d3e3f..75295462 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt
@@ -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, 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()) }
- 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"
+ )
}
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt
index 102faf1d..ccf769d6 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt
@@ -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()
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)
}
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt
index 9f7901f0..8ef1d17f 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt
@@ -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() }
- 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(null) }
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt
index e8c1ebca..d56fa2a0 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt
@@ -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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt
index 0929d8ae..f631f224 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt
@@ -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)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt
index 59b14873..0bb6fb2b 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt
@@ -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 by mutableStateOf(emptyList())
private var groupList: List 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) {
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt
new file mode 100644
index 00000000..dd948307
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt
@@ -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,
+ 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())
+ 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))
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt
new file mode 100644
index 00000000..f58c2ef8
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt
@@ -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() }
+ 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(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()
+ }
+
+ 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(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()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt
index 81656be8..49ca72f1 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt
@@ -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)
diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json
index 70807f4c..a6cf66b6 100644
--- a/common/src/main/assets/lang/en_US.json
+++ b/common/src/main/assets/lang/en_US.json
@@ -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": {
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt
index 91377c5b..079ac2be 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt
@@ -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"
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt
index 4cb0b504..5d818e7b 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt
@@ -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()
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 {
+ val edits = mutableListOf()
+ 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,
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt
index 22bfc1a1..38f98ca2 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt
@@ -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,
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt
index c405722a..9947d432 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt
@@ -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) }
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt
new file mode 100644
index 00000000..ea5de176
--- /dev/null
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt
@@ -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)
+}
\ No newline at end of file
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt
index 57e31656..4812cc80 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt
@@ -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() }
}
\ No newline at end of file
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt
index 324df0d1..a1da60b0 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt
@@ -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>
-): 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>,
+): Parcelable {
+ fun getActions(): Map {
+ 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
+): 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
+): Parcelable
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt
index 6d80e0b0..e261f6a3 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt
@@ -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()
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?): 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"
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt
index a61316fd..30bf2777 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt
@@ -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
+ }
}
}
\ No newline at end of file
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt
new file mode 100644
index 00000000..e1e9db60
--- /dev/null
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt
@@ -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 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 rememberCommonState(
+ initialState: () -> T,
+ setter: suspend T.() -> Unit,
+ updateDispatcher: AsyncUpdateDispatcher? = null,
+ keys: Array<*> = emptyArray(),
+): 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 rememberAsyncMutableState(
+ defaultValue: T,
+ updateDispatcher: AsyncUpdateDispatcher? = null,
+ keys: Array<*> = emptyArray(),
+ getter: () -> T,
+): MutableState {
+ return rememberCommonState(
+ initialState = { mutableStateOf(defaultValue) },
+ setter = {
+ withContext(Dispatchers.Main) {
+ value = withContext(Dispatchers.IO) {
+ getter()
+ }
+ }
+ },
+ updateDispatcher = updateDispatcher,
+ keys = keys,
+ )
+}
+
+@Composable
+fun rememberAsyncMutableStateList(
+ defaultValue: List,
+ updateDispatcher: AsyncUpdateDispatcher? = null,
+ keys: Array<*> = emptyArray(),
+ getter: () -> List,
+): SnapshotStateList {
+ return rememberCommonState(
+ initialState = { mutableStateListOf().apply {
+ addAll(defaultValue)
+ }},
+ setter = {
+ withContext(Dispatchers.Main) {
+ clear()
+ addAll(withContext(Dispatchers.IO) {
+ getter()
+ })
+ }
+ },
+ updateDispatcher = updateDispatcher,
+ keys = keys,
+ )
+}
+
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt
index e1973fcb..98de28f6 100644
--- a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt
+++ b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt
@@ -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"
}
}
}
\ No newline at end of file
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt
index 94c7da49..85e0e4c8 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt
@@ -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
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt
index 9544d710..0fbdfbc8 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt
@@ -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(
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt
index f3d1dbd4..7f7af52f 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt
@@ -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
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt
index 6157a38c..56339e69 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt
@@ -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(),
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt
index c3db0d7a..e66344de 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt
@@ -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
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt
index ca845bf1..e3cbd39b 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt
@@ -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")
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt
similarity index 78%
rename from core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt
rename to core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt
index 7cbfbc9b..52d4dbd6 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt
@@ -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>() // 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)
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt
index 4ae80811..b1389b7c 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt
@@ -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 {
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt
index 481e1f0a..c8bbd890 100644
--- a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt
+++ b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt
@@ -19,12 +19,10 @@ abstract class AbstractWrapper(
inner class FieldAccessor(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?) {
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt
index 20d5f916..511c6d5b 100644
--- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt
+++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt
@@ -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")
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt
index 009ba6dd..3803536d 100644
--- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt
+++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt
@@ -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") {
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt
index 7f27dd1e..ed7285fd 100644
--- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt
+++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt
@@ -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")
diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt
index 1ef08386..153d726e 100644
--- a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt
+++ b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt
@@ -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")