feat: friend tracker experiment

This commit is contained in:
rhunk
2024-02-19 00:42:31 +01:00
parent 80386d6f58
commit 4aab812e5c
17 changed files with 856 additions and 36 deletions

View File

@@ -23,8 +23,8 @@ import me.rhunk.snapenhance.bridge.BridgeService
import me.rhunk.snapenhance.common.BuildConfig
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.common.config.ModConfig
import me.rhunk.snapenhance.e2ee.E2EEImplementation
import me.rhunk.snapenhance.messaging.ModDatabase
@@ -69,7 +69,8 @@ class RemoteSideContext(
val scriptManager = RemoteScriptManager(this)
val settingsOverlay = SettingsOverlay(this)
val e2eeImplementation = E2EEImplementation(this)
val messageLogger by lazy { MessageLoggerWrapper(androidContext.getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)) }
val messageLogger by lazy { LoggerWrapper(androidContext.getDatabasePath(BridgeFileType.MESSAGE_LOGGER_DATABASE.fileName)) }
val tracker = RemoteTracker(this)
//used to load bitmoji selfies and download previews
val imageLoader by lazy {
@@ -108,6 +109,7 @@ class RemoteSideContext(
streaksReminder.init()
scriptManager.init()
messageLogger.init()
tracker.init()
config.root.messaging.messageLogger.takeIf {
it.globalState == true
}?.getAutoPurgeTime()?.let {

View File

@@ -0,0 +1,29 @@
package me.rhunk.snapenhance
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
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
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)
}*/
}
override fun getTrackedEvents(eventType: String): String? {
val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>()
context.modDatabase.getTrackerEvents(eventType).forEach { (event, rule) ->
events.getOrPut(rule) { mutableListOf() }.add(event)
}
return TrackerEventsResult(events).toSerialized()
}
}

View File

@@ -176,7 +176,8 @@ class BridgeService : Service() {
override fun getScriptingInterface() = remoteSideContext.scriptManager
override fun getE2eeInterface() = remoteSideContext.e2eeImplementation
override fun getMessageLogger() = remoteSideContext.messageLogger
override fun getLogger() = remoteSideContext.messageLogger
override fun getTracker() = remoteSideContext.tracker
override fun registerMessagingBridge(bridge: MessagingBridge) {
messagingBridge = bridge
}

View File

@@ -1,17 +1,17 @@
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.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.*
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(
@@ -67,6 +67,18 @@ class ModDatabase(
"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",
)
))
}
@@ -333,4 +345,90 @@ class ModDatabase(
}
}
}
fun addTrackerRule(flags: Int, conversationId: String?, userId: String?): Int {
return runBlocking {
suspendCoroutine { continuation ->
executeAsync {
val id = database.insert("tracker_rules", null, ContentValues().apply {
put("flags", flags)
put("conversation_id", conversationId)
put("user_id", userId)
})
continuation.resumeWith(Result.success(id.toInt()))
}
}
}
}
fun addTrackerRuleEvent(ruleId: Int, flags: Int, eventType: String) {
executeAsync {
database.execSQL("INSERT INTO tracker_rules_events (flags, rule_id, event_type) VALUES (?, ?, ?)", arrayOf(
flags,
ruleId,
eventType
))
}
}
fun getTrackerRules(conversationId: String?, userId: String?): List<TrackerRule> {
val rules = mutableListOf<TrackerRule>()
database.rawQuery("SELECT * FROM tracker_rules WHERE (conversation_id = ? OR conversation_id IS NULL) AND (user_id = ? OR user_id IS NULL)", arrayOf(conversationId, userId).filterNotNull().toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
rules.add(
TrackerRule(
id = cursor.getInteger("id"),
flags = cursor.getInteger("flags"),
conversationId = cursor.getStringOrNull("conversation_id"),
userId = cursor.getStringOrNull("user_id")
)
)
}
}
return rules
}
fun getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> {
val events = mutableListOf<TrackerRuleEvent>()
database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor ->
while (cursor.moveToNext()) {
events.add(
TrackerRuleEvent(
id = cursor.getInteger("id"),
flags = cursor.getInteger("flags"),
eventType = cursor.getStringOrNull("event_type") ?: continue
)
)
}
}
return events
}
fun getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> {
val events = mutableMapOf<TrackerRuleEvent, TrackerRule>()
database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.conversation_id, tracker_rules.user_id " +
"FROM tracker_rules_events " +
"INNER JOIN tracker_rules " +
"ON tracker_rules_events.rule_id = tracker_rules.id " +
"WHERE event_type = ?", arrayOf(eventType)
).use { cursor ->
while (cursor.moveToNext()) {
val trackerRule = TrackerRule(
id = -1,
flags = cursor.getInteger("flags"),
conversationId = cursor.getStringOrNull("conversation_id"),
userId = cursor.getStringOrNull("user_id")
)
val trackerRuleEvent = TrackerRuleEvent(
id = cursor.getInteger("event_id"),
flags = cursor.getInteger("flags"),
eventType = cursor.getStringOrNull("event_type") ?: continue
)
events[trackerRuleEvent] = trackerRule
}
}
return events
}
}

View File

@@ -15,6 +15,7 @@ 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
@@ -53,6 +54,7 @@ class Routes(
val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home)
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 social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)

View File

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

View File

@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage
import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper
import me.rhunk.snapenhance.common.data.ContentType
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.copyToClipboard
@@ -40,7 +40,7 @@ import kotlin.math.absoluteValue
class LoggerHistoryRoot : Routes.Route() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
private lateinit var loggerWrapper: LoggerWrapper
private var selectedConversation by mutableStateOf<String?>(null)
private var stringFilter by mutableStateOf("")
private var reverseOrder by mutableStateOf(true)
@@ -182,7 +182,7 @@ class LoggerHistoryRoot : Routes.Route() {
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
LaunchedEffect(Unit) {
messageLoggerWrapper = MessageLoggerWrapper(
loggerWrapper = LoggerWrapper(
context.androidContext.getDatabasePath("message_logger.db")
)
}
@@ -208,7 +208,7 @@ class LoggerHistoryRoot : Routes.Route() {
LaunchedEffect(Unit) {
conversations.clear()
withContext(Dispatchers.IO) {
conversations.addAll(messageLoggerWrapper.getAllConversations())
conversations.addAll(loggerWrapper.getAllConversations())
}
}
@@ -270,7 +270,7 @@ class LoggerHistoryRoot : Routes.Route() {
}
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
withContext(Dispatchers.IO) {
val newMessages = messageLoggerWrapper.fetchMessages(
val newMessages = loggerWrapper.fetchMessages(
selectedConversation ?: return@withContext,
lastFetchMessageTimestamp,
30,

View File

@@ -271,6 +271,18 @@ class HomeRoot : Routes.Route() {
updateInstallationSummary(coroutineScope)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
ElevatedButton(onClick = {
routes.friendTracker.navigate()
}) {
Text(text = "Friend Tracker", fontSize = 18.sp)
}
}
Spacer(modifier = Modifier.height(20.dp))
installationSummary?.let { SummaryCards(installationSummary = it) }
}
}

View File

@@ -5,7 +5,8 @@ import me.rhunk.snapenhance.bridge.DownloadCallback;
import me.rhunk.snapenhance.bridge.SyncCallback;
import me.rhunk.snapenhance.bridge.scripting.IScripting;
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface;
import me.rhunk.snapenhance.bridge.MessageLoggerInterface;
import me.rhunk.snapenhance.bridge.logger.LoggerInterface;
import me.rhunk.snapenhance.bridge.logger.TrackerInterface;
import me.rhunk.snapenhance.bridge.ConfigStateListener;
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge;
@@ -79,7 +80,9 @@ interface BridgeInterface {
E2eeInterface getE2eeInterface();
MessageLoggerInterface getMessageLogger();
LoggerInterface getLogger();
TrackerInterface getTracker();
oneway void registerMessagingBridge(MessagingBridge bridge);

View File

@@ -1,6 +1,6 @@
package me.rhunk.snapenhance.bridge;
package me.rhunk.snapenhance.bridge.logger;
interface MessageLoggerInterface {
interface LoggerInterface {
/**
* Get the ids of the messages that are logged
* @return message ids that are logged
@@ -26,4 +26,14 @@ interface MessageLoggerInterface {
* Add a story to the message logger database if it is not already there
*/
boolean addStory(String userId, String url, long postedAt, long createdAt, in byte[] key, in byte[] iv);
oneway void logTrackerEvent(
String conversationId,
String conversationTitle,
boolean isGroup,
String username,
String userId,
String eventType,
String data
);
}

View File

@@ -0,0 +1,5 @@
package me.rhunk.snapenhance.bridge.logger;
interface TrackerInterface {
String getTrackedEvents(String eventType); // returns serialized TrackerEventsResult
}

View File

@@ -3,10 +3,11 @@ package me.rhunk.snapenhance.common.bridge.wrapper
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.*
import me.rhunk.snapenhance.bridge.MessageLoggerInterface
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
import me.rhunk.snapenhance.common.data.StoryData
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 java.io.File
@@ -19,9 +20,20 @@ class LoggedMessage(
val messageData: ByteArray
)
class MessageLoggerWrapper(
class TrackerLog(
val timestamp: Long,
val conversationId: String,
val conversationTitle: String?,
val isGroup: Boolean,
val username: String,
val userId: String,
val eventType: String,
val data: String
)
class LoggerWrapper(
val databaseFile: File
): MessageLoggerInterface.Stub() {
): LoggerInterface.Stub() {
private var _database: SQLiteDatabase? = null
@OptIn(ExperimentalCoroutinesApi::class)
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
@@ -47,6 +59,17 @@ class MessageLoggerWrapper(
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
))
_database = openedDatabase
@@ -159,6 +182,76 @@ class MessageLoggerWrapper(
return true
}
override fun logTrackerEvent(
conversationId: String,
conversationTitle: String?,
isGroup: Boolean,
username: String,
userId: String,
eventType: String,
data: String
) {
runBlocking {
withContext(coroutineScope.coroutineContext) {
database.insert("tracker_events", null, ContentValues().apply {
put("timestamp", System.currentTimeMillis())
put("conversation_id", conversationId)
put("conversation_title", conversationTitle)
put("is_group", isGroup)
put("username", username)
put("user_id", userId)
put("event_type", eventType)
put("data", data)
})
}
}
}
fun getLogs(
lastTimestamp: Long,
filter: ((TrackerLog) -> Boolean)? = null
): List<TrackerLog> {
return database.rawQuery("SELECT * FROM tracker_events WHERE timestamp < ? ORDER BY timestamp DESC", arrayOf(lastTimestamp.toString())).use {
val logs = mutableListOf<TrackerLog>()
while (it.moveToNext() && logs.size < 50) {
val log = TrackerLog(
timestamp = it.getLongOrNull("timestamp") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue,
conversationTitle = it.getStringOrNull("conversation_title"),
isGroup = it.getIntOrNull("is_group") == 1,
username = it.getStringOrNull("username") ?: continue,
userId = it.getStringOrNull("user_id") ?: continue,
eventType = it.getStringOrNull("event_type") ?: continue,
data = it.getStringOrNull("data") ?: continue
)
if (filter != null && !filter(log)) continue
logs.add(log)
}
logs
}
}
fun findConversation(search: String): List<String> {
return database.rawQuery("SELECT DISTINCT conversation_id FROM tracker_events WHERE is_group = 1 AND conversation_id LIKE ?", arrayOf("%$search%")).use {
val conversations = mutableListOf<String>()
while (it.moveToNext()) {
conversations.add(it.getString(0))
}
conversations
}
}
fun findUsername(search: String): List<String> {
return database.rawQuery("SELECT DISTINCT username FROM tracker_events WHERE username LIKE ?", arrayOf("%$search%")).use {
val usernames = mutableListOf<String>()
while (it.moveToNext()) {
usernames.add(it.getString(0))
}
usernames
}
}
fun getStories(userId: String, from: Long, limit: Int = Int.MAX_VALUE): Map<Long, StoryData> {
val stories = sortedMapOf<Long, StoryData>()
database.rawQuery("SELECT * FROM stories WHERE user_id = ? AND posted_timestamp < ? ORDER BY posted_timestamp DESC LIMIT $limit", arrayOf(userId, from.toString())).use {

View File

@@ -1,5 +1,8 @@
package me.rhunk.snapenhance.common.data
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
data class FriendPresenceState(
val bitmojiPresent: Boolean,
@@ -40,3 +43,94 @@ enum class SessionEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
}
object TrackerFlags {
const val TRACK = 1
const val LOG = 2
const val NOTIFY = 4
const val APP_IS_ACTIVE = 8
const val APP_IS_INACTIVE = 16
const val IS_IN_CONVERSATION = 32
}
@Parcelize
class TrackerEventsResult(
private val rules: Map<TrackerRule, List<TrackerRuleEvent>>
): Parcelable {
fun hasFlags(vararg flags: Int): Boolean {
return rules.any { (_, ruleEvents) ->
ruleEvents.any { flags.all { flag -> it.flags and flag != 0 } }
}
}
fun canTrackOn(conversationId: String?, userId: String?): Boolean {
return rules.any t@{ (rule, ruleEvents) ->
ruleEvents.any { event ->
if (event.flags and TrackerFlags.TRACK == 0) {
return@any false
}
// global rule
if (rule.conversationId == null && rule.userId == null) {
return@any true
}
// user rule
if (rule.conversationId == null && rule.userId == userId) {
return@any true
}
// conversation rule
if (rule.conversationId == conversationId && rule.userId == null) {
return@any true
}
// conversation and user rule
return@any rule.conversationId == conversationId && rule.userId == userId
}
}
}
}
@Parcelize
data class TrackerRule(
val id: Int,
val flags: Int,
val conversationId: String?,
val userId: String?
): Parcelable
@Parcelize
data class TrackerRuleEvent(
val id: Int,
val flags: Int,
val eventType: String,
): Parcelable
enum class TrackerEventType(
val key: String
) {
// pcs events
CONVERSATION_ENTER("conversation_enter"),
CONVERSATION_EXIT("conversation_exit"),
STARTED_TYPING("started_typing"),
STOPPED_TYPING("stopped_typing"),
STARTED_SPEAKING("started_speaking"),
STOPPED_SPEAKING("stopped_speaking"),
STARTED_PEEKING("started_peeking"),
STOPPED_PEEKING("stopped_peeking"),
// mcs events
MESSAGE_READ("message_read"),
MESSAGE_DELETED("message_deleted"),
MESSAGE_SAVED("message_saved"),
MESSAGE_UNSAVED("message_unsaved"),
MESSAGE_REACTION_ADD("message_reaction_add"),
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
SNAP_OPENED("snap_opened"),
SNAP_REPLAYED("snap_replayed"),
SNAP_REPLAYED_TWICE("snap_replayed_twice"),
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
}

View File

@@ -14,9 +14,10 @@ import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.bridge.BridgeInterface
import me.rhunk.snapenhance.bridge.ConfigStateListener
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.bridge.MessageLoggerInterface
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
import me.rhunk.snapenhance.bridge.SyncCallback
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
import me.rhunk.snapenhance.common.Constants
@@ -191,11 +192,13 @@ class BridgeClient(
service.setRule(targetUuid, type.key, state)
}
fun getScriptingInterface(): IScripting = safeServiceCall { service.getScriptingInterface() }
fun getScriptingInterface(): IScripting = safeServiceCall { service.scriptingInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.getE2eeInterface() }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }
fun getMessageLogger(): MessageLoggerInterface = safeServiceCall { service.messageLogger }
fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger }
fun getTracker(): TrackerInterface = safeServiceCall { service.tracker }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }

View File

@@ -252,6 +252,17 @@ class DatabaseAccess(
}
}
fun getConversationServerMessage(conversationId: String, serverId: Long): ConversationMessage? {
return useDatabase(DatabaseType.ARROYO)?.performOperation {
readDatabaseObject(
ConversationMessage(),
"conversation_message",
"client_conversation_id = ? AND server_message_id = ?",
arrayOf(conversationId, serverId.toString())
)
}
}
fun getConversationType(conversationId: String): Int? {
return useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery(

View File

@@ -1,12 +1,15 @@
package me.rhunk.snapenhance.core.features.impl.experiments
import me.rhunk.snapenhance.common.data.SessionMessageEvent
import me.rhunk.snapenhance.common.data.SessionEvent
import me.rhunk.snapenhance.common.data.SessionEventType
import me.rhunk.snapenhance.common.data.FriendPresenceState
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import me.rhunk.snapenhance.common.Constants
import me.rhunk.snapenhance.common.data.*
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.common.util.toParcelable
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
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
@@ -17,6 +20,43 @@ import java.nio.ByteBuffer
class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.INIT_SYNC) {
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val tracker by lazy { context.bridgeClient.getTracker() }
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
return runCatching {
tracker.getTrackedEvents(eventType.key)?.let {
toParcelable<TrackerEventsResult>(it)
}
}.onFailure {
context.log.error("Failed to get tracked events for $eventType", it)
}.getOrNull()
}
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(
id,
Notification.Builder(
context.androidContext,
"general_group_generic_push_noisy_generic_push_B~LVSD2"
)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.setContentIntent(context.androidContext.packageManager.getLaunchIntentForPackage(
Constants.SNAPCHAT_PACKAGE_NAME
)?.let {
PendingIntent.getActivity(
context.androidContext,
0, it, PendingIntent.FLAG_IMMUTABLE
)
})
.setContentText(text)
.build()
)
}
private fun handleVolatileEvent(protoReader: ProtoReader) {
context.log.verbose("volatile event\n$protoReader")
@@ -24,10 +64,97 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState")
val eventType = when {
(oldState == null || currentState?.bitmojiPresent == false) && currentState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_ENTER
(currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT
oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING
oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING
(oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING
oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING
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,
""
)
}
}
}
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
SessionEventType.MESSAGE_DELETED -> TrackerEventType.MESSAGE_DELETED
SessionEventType.MESSAGE_REACTION_ADD -> TrackerEventType.MESSAGE_REACTION_ADD
SessionEventType.MESSAGE_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE
SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
SessionEventType.SNAP_SCREENSHOT -> TrackerEventType.SNAP_SCREENSHOT
SessionEventType.SNAP_SCREEN_RECORD -> TrackerEventType.SNAP_SCREEN_RECORD
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
} ?: ""
)
}
}
}
private fun handlePresenceEvent(protoReader: ProtoReader) {
@@ -66,7 +193,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
private fun handleMessagingEvent(protoReader: ProtoReader) {
// read receipts
protoReader.followPath(12) {
val conversationId = getByteArray(1, 1)?.toSnapUUID().toString() ?: return@followPath
val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
followPath(7) readReceipts@{
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@readReceipts
@@ -84,8 +211,8 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
}
protoReader.followPath(6, 2) {
val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
val senderId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
val serverMessageId = getVarInt(2) ?: return@followPath
if (contains(4)) {

View File

@@ -33,7 +33,7 @@ class MessageLogger : Feature("MessageLogger",
const val DELETED_MESSAGE_COLOR = 0x6Eb71c1c
}
private val messageLoggerInterface by lazy { context.bridgeClient.getMessageLogger() }
private val loggerInterface by lazy { context.bridgeClient.getMessageLogger() }
val isEnabled get() = context.config.messaging.messageLogger.globalState == true
@@ -50,7 +50,7 @@ class MessageLogger : Feature("MessageLogger",
val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return
fetchedMessages.remove(uniqueMessageId)
deletedMessageCache.remove(uniqueMessageId)
messageLoggerInterface.deleteMessage(conversationId, uniqueMessageId)
loggerInterface.deleteMessage(conversationId, uniqueMessageId)
}
fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? {
@@ -58,7 +58,7 @@ class MessageLogger : Feature("MessageLogger",
if (deletedMessageCache.containsKey(uniqueMessageId)) {
return deletedMessageCache[uniqueMessageId]
}
return messageLoggerInterface.getMessage(conversationId, uniqueMessageId)?.let {
return loggerInterface.getMessage(conversationId, uniqueMessageId)?.let {
JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject
}
}
@@ -93,7 +93,7 @@ class MessageLogger : Feature("MessageLogger",
measureTimeMillis {
val conversationIds = context.database.getFeedEntries(PREFETCH_FEED_COUNT).map { it.key!! }
if (conversationIds.isEmpty()) return@measureTimeMillis
fetchedMessages.addAll(messageLoggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList())
fetchedMessages.addAll(loggerInterface.getLoggedIds(conversationIds.toTypedArray(), PREFETCH_MESSAGE_COUNT).toList())
}.also { context.log.verbose("Loaded ${fetchedMessages.size} cached messages in ${it}ms") }
}
@@ -124,7 +124,7 @@ class MessageLogger : Feature("MessageLogger",
threadPool.execute {
try {
messageLoggerInterface.addMessage(conversationId, uniqueMessageIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8))
loggerInterface.addMessage(conversationId, uniqueMessageIdentifier, context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8))
} catch (ignored: DeadObjectException) {}
}
@@ -135,7 +135,7 @@ class MessageLogger : Feature("MessageLogger",
val deletedMessageObject: JsonObject = if (deletedMessageCache.containsKey(uniqueMessageIdentifier))
deletedMessageCache[uniqueMessageIdentifier]
else {
messageLoggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let {
loggerInterface.getMessage(conversationId, uniqueMessageIdentifier)?.let {
JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject
}
} ?: return@subscribe