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) }
}
}