commit 4aab812e5c161780ec5e08ea4e9a82e8ff5d1f43
parent 80386d6f582a9fe0b96e6a349857de0a7da35cbe
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Mon, 19 Feb 2024 00:42:31 +0100

feat: friend tracker experiment

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 6++++--
Aapp/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt | 30++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt | 3++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt | 2++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt | 10+++++-----
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt | 12++++++++++++
Mcommon/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl | 7+++++--
Dcommon/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl | 30------------------------------
Acommon/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl | 40++++++++++++++++++++++++++++++++++++++++
Acommon/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl | 6++++++
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt | 213-------------------------------------------------------------------------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt | 11+++++++----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt | 11+++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt | 12++++++------
19 files changed, 1097 insertions(+), 274 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -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 { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt @@ -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() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -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 } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -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 + } } \ No newline at end of file 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 @@ -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) 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 @@ -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() + } + } + } + } +}+ \ 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 @@ -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, 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 @@ -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) } } } diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -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); diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl @@ -1,29 +0,0 @@ -package me.rhunk.snapenhance.bridge; - -interface MessageLoggerInterface { - /** - * Get the ids of the messages that are logged - * @return message ids that are logged - */ - long[] getLoggedIds(in String[] conversationIds, int limit); - - /** - * Get the content of a logged message from the database - */ - @nullable byte[] getMessage(String conversationId, long id); - - /** - * Add a message to the message logger database if it is not already there - */ - oneway void addMessage(String conversationId, long id, in byte[] message); - - /** - * Delete a message from the message logger database - */ - oneway void deleteMessage(String conversationId, long id); - - /** - * 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); -}- \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/LoggerInterface.aidl @@ -0,0 +1,39 @@ +package me.rhunk.snapenhance.bridge.logger; + +interface LoggerInterface { + /** + * Get the ids of the messages that are logged + * @return message ids that are logged + */ + long[] getLoggedIds(in String[] conversationIds, int limit); + + /** + * Get the content of a logged message from the database + */ + @nullable byte[] getMessage(String conversationId, long id); + + /** + * Add a message to the message logger database if it is not already there + */ + oneway void addMessage(String conversationId, long id, in byte[] message); + + /** + * Delete a message from the message logger database + */ + oneway void deleteMessage(String conversationId, long id); + + /** + * 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 + ); +}+ \ No newline at end of file diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/logger/TrackerInterface.aidl @@ -0,0 +1,5 @@ +package me.rhunk.snapenhance.bridge.logger; + +interface TrackerInterface { + String getTrackedEvents(String eventType); // returns serialized TrackerEventsResult +}+ \ No newline at end of file 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 @@ -0,0 +1,305 @@ +package me.rhunk.snapenhance.common.bridge.wrapper + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import kotlinx.coroutines.* +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 +import java.util.UUID + + +class LoggedMessage( + val messageId: Long, + val timestamp: Long, + val messageData: ByteArray +) + +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 +): LoggerInterface.Stub() { + private var _database: SQLiteDatabase? = null + @OptIn(ExperimentalCoroutinesApi::class) + private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + + private val database get() = synchronized(this) { + _database?.takeIf { it.isOpen } ?: run { + _database?.close() + val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) + SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf( + "messages" to listOf( + "id INTEGER PRIMARY KEY", + "added_timestamp BIGINT", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_data BLOB" + ), + "stories" to listOf( + "id INTEGER PRIMARY KEY", + "added_timestamp BIGINT", + "user_id VARCHAR", + "posted_timestamp BIGINT", + "created_timestamp BIGINT", + "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 + openedDatabase + } + } + + protected fun finalize() { + _database?.close() + } + + fun init() { + + } + + override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { + if (conversationId.any { + runCatching { UUID.fromString(it) }.isFailure + }) return longArrayOf() + + return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ + conversationId.joinToString(",") { "'$it'" } + }) ORDER BY message_id DESC LIMIT $limit", null).use { + val ids = mutableListOf<Long>() + while (it.moveToNext()) { + ids.add(it.getLong(0)) + } + ids.toLongArray() + } + } + + override fun getMessage(conversationId: String?, id: Long): ByteArray? { + return database.rawQuery( + "SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", + arrayOf(conversationId, id.toString()) + ).use { + if (it.moveToFirst()) it.getBlob(0) else null + } + } + + 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 + 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) + }) + } + } + } + + fun purgeAll(maxAge: Long? = null) { + coroutineScope.launch { + maxAge?.let { + val maxTime = System.currentTimeMillis() - it + database.execSQL("DELETE FROM messages 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 stories") + } + } + } + + fun getStoredMessageCount(): Int { + return database.rawQuery("SELECT COUNT(*) FROM messages", null).use { + it.moveToFirst() + it.getInt(0) + } + } + + fun getStoredStoriesCount(): Int { + return database.rawQuery("SELECT COUNT(*) FROM stories", null).use { + it.moveToFirst() + it.getInt(0) + } + } + + override fun deleteMessage(conversationId: String, messageId: Long) { + coroutineScope.launch { + database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + } + } + + override fun addStory(userId: String, url: String, postedAt: Long, createdAt: Long, key: ByteArray?, iv: ByteArray?): Boolean { + if (database.rawQuery("SELECT id FROM stories WHERE user_id = ? AND url = ?", arrayOf(userId, url)).use { + it.moveToFirst() + }) { + return false + } + runBlocking { + withContext(coroutineScope.coroutineContext) { + database.insert("stories", null, ContentValues().apply { + put("user_id", userId) + put("added_timestamp", System.currentTimeMillis()) + put("url", url) + put("posted_timestamp", postedAt) + put("created_timestamp", createdAt) + put("encryption_key", key) + put("encryption_iv", iv) + }) + } + } + 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 { + while (it.moveToNext()) { + stories[it.getLongOrNull("posted_timestamp") ?: continue] = StoryData( + url = it.getStringOrNull("url") ?: continue, + postedAt = it.getLongOrNull("posted_timestamp") ?: continue, + createdAt = it.getLongOrNull("created_timestamp") ?: continue, + key = it.getBlobOrNull("encryption_key"), + iv = it.getBlobOrNull("encryption_iv") + ) + } + } + return stories + } + + fun getAllConversations(): List<String> { + return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use { + val conversations = mutableListOf<String>() + while (it.moveToNext()) { + conversations.add(it.getString(0)) + } + conversations + } + } + + fun fetchMessages( + conversationId: String, + fromTimestamp: Long, + limit: Int, + reverseOrder: Boolean = true, + filter: ((LoggedMessage) -> Boolean)? = null + ): List<LoggedMessage> { + val messages = mutableListOf<LoggedMessage>() + database.rawQuery( + "SELECT * FROM messages WHERE conversation_id = ? AND added_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY added_timestamp ${if (reverseOrder) "DESC" else "ASC"}", + arrayOf(conversationId, fromTimestamp.toString()) + ).use { + while (it.moveToNext() && messages.size < limit) { + val message = LoggedMessage( + messageId = it.getLongOrNull("message_id") ?: continue, + timestamp = it.getLongOrNull("added_timestamp") ?: continue, + messageData = it.getBlobOrNull("message_data") ?: continue + ) + if (filter != null && !filter(message)) continue + messages.add(message) + } + } + return messages + } +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt @@ -1,212 +0,0 @@ -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.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.getLongOrNull -import me.rhunk.snapenhance.common.util.ktx.getStringOrNull -import java.io.File -import java.util.UUID - - -class LoggedMessage( - val messageId: Long, - val timestamp: Long, - val messageData: ByteArray -) - -class MessageLoggerWrapper( - val databaseFile: File -): MessageLoggerInterface.Stub() { - private var _database: SQLiteDatabase? = null - @OptIn(ExperimentalCoroutinesApi::class) - private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) - - private val database get() = synchronized(this) { - _database?.takeIf { it.isOpen } ?: run { - _database?.close() - val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) - SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf( - "messages" to listOf( - "id INTEGER PRIMARY KEY", - "added_timestamp BIGINT", - "conversation_id VARCHAR", - "message_id BIGINT", - "message_data BLOB" - ), - "stories" to listOf( - "id INTEGER PRIMARY KEY", - "added_timestamp BIGINT", - "user_id VARCHAR", - "posted_timestamp BIGINT", - "created_timestamp BIGINT", - "url VARCHAR", - "encryption_key BLOB", - "encryption_iv BLOB" - ) - )) - _database = openedDatabase - openedDatabase - } - } - - protected fun finalize() { - _database?.close() - } - - fun init() { - - } - - override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray { - if (conversationId.any { - runCatching { UUID.fromString(it) }.isFailure - }) return longArrayOf() - - return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ - conversationId.joinToString(",") { "'$it'" } - }) ORDER BY message_id DESC LIMIT $limit", null).use { - val ids = mutableListOf<Long>() - while (it.moveToNext()) { - ids.add(it.getLong(0)) - } - ids.toLongArray() - } - } - - override fun getMessage(conversationId: String?, id: Long): ByteArray? { - return database.rawQuery( - "SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", - arrayOf(conversationId, id.toString()) - ).use { - if (it.moveToFirst()) it.getBlob(0) else null - } - } - - 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 - 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) - }) - } - } - } - - fun purgeAll(maxAge: Long? = null) { - coroutineScope.launch { - maxAge?.let { - val maxTime = System.currentTimeMillis() - it - database.execSQL("DELETE FROM messages 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 stories") - } - } - } - - fun getStoredMessageCount(): Int { - return database.rawQuery("SELECT COUNT(*) FROM messages", null).use { - it.moveToFirst() - it.getInt(0) - } - } - - fun getStoredStoriesCount(): Int { - return database.rawQuery("SELECT COUNT(*) FROM stories", null).use { - it.moveToFirst() - it.getInt(0) - } - } - - override fun deleteMessage(conversationId: String, messageId: Long) { - coroutineScope.launch { - database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - } - } - - override fun addStory(userId: String, url: String, postedAt: Long, createdAt: Long, key: ByteArray?, iv: ByteArray?): Boolean { - if (database.rawQuery("SELECT id FROM stories WHERE user_id = ? AND url = ?", arrayOf(userId, url)).use { - it.moveToFirst() - }) { - return false - } - runBlocking { - withContext(coroutineScope.coroutineContext) { - database.insert("stories", null, ContentValues().apply { - put("user_id", userId) - put("added_timestamp", System.currentTimeMillis()) - put("url", url) - put("posted_timestamp", postedAt) - put("created_timestamp", createdAt) - put("encryption_key", key) - put("encryption_iv", iv) - }) - } - } - return true - } - - 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 { - while (it.moveToNext()) { - stories[it.getLongOrNull("posted_timestamp") ?: continue] = StoryData( - url = it.getStringOrNull("url") ?: continue, - postedAt = it.getLongOrNull("posted_timestamp") ?: continue, - createdAt = it.getLongOrNull("created_timestamp") ?: continue, - key = it.getBlobOrNull("encryption_key"), - iv = it.getBlobOrNull("encryption_iv") - ) - } - } - return stories - } - - fun getAllConversations(): List<String> { - return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use { - val conversations = mutableListOf<String>() - while (it.moveToNext()) { - conversations.add(it.getString(0)) - } - conversations - } - } - - fun fetchMessages( - conversationId: String, - fromTimestamp: Long, - limit: Int, - reverseOrder: Boolean = true, - filter: ((LoggedMessage) -> Boolean)? = null - ): List<LoggedMessage> { - val messages = mutableListOf<LoggedMessage>() - database.rawQuery( - "SELECT * FROM messages WHERE conversation_id = ? AND added_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY added_timestamp ${if (reverseOrder) "DESC" else "ASC"}", - arrayOf(conversationId, fromTimestamp.toString()) - ).use { - while (it.moveToNext() && messages.size < limit) { - val message = LoggedMessage( - messageId = it.getLongOrNull("message_id") ?: continue, - timestamp = it.getLongOrNull("added_timestamp") ?: continue, - messageData = it.getBlobOrNull("message_data") ?: continue - ) - if (filter != null && !filter(message)) continue - messages.add(message) - } - } - return messages - } -}- \ 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 @@ -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"), +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -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) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/database/DatabaseAccess.kt @@ -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( 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/experiments/SessionEvents.kt @@ -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)) { 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 @@ -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