commit dadec3d2783da9eddbc1887090377601b6a5eb39
parent 43fb83ab5c91fe4c852c619bde0574138a1e6408
Author: auth <64337177+authorisation@users.noreply.github.com>
Date:   Tue, 21 May 2024 20:48:47 +0200

feat: friend tracker (#969)

Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
Co-authored-by: Jacob Thomas <41988041+bocajthomas@users.noreply.github.com>
Diffstat:
Mapp/src/main/AndroidManifest.xml | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt | 57+++++++++++++++++++++++++++++++++------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt | 6++++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt | 52+++++++++++++++++++++++++++++-----------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt | 16++++++++--------
Aapp/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt | 15++++++++-------
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt | 1-
Dapp/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt | 406-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt | 127-------------------------------------------------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt | 26+++++++++++++++-----------
Mapp/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt | 49+++++++++++++++++++++++++++++++++++++++++++++----
Aapp/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt | 27+++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt | 4++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt | 4+++-
Dapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt | 331-------------------------------------------------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt | 44+++++++++++++++++++++++++++++++++-----------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt | 129++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt | 30+++++++++---------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt | 115+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt | 337+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt | 18++++++++++--------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt | 79+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt | 4++--
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt | 195++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt | 10++++++----
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt | 46++++++++++++++++++++++++++++++++--------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt | 2+-
Mcommon/src/main/assets/lang/en_US.json | 30++++++++++++++++--------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt | 18+++++++++++++-----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt | 3++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 13+++++--------
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt | 9+++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt | 23++++++++++++++---------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt | 167+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt | 26+++++++++++++++++++++-----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt | 47+++++++++++++++++++++++++++--------------------
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt | 4++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt | 34+++++++++++++++++++++++++++++-----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt | 1-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt | 3++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt | 3---
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt | 360-------------------------------------------------------------------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/MessageLogger.kt | 6++++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt | 10++++------
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt | 2+-
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt | 8++++----
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt | 2+-
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt | 4++--
61 files changed, 3533 insertions(+), 1937 deletions(-)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -65,7 +65,7 @@ android:excludeFromRecents="true" android:exported="true" /> - <receiver android:name=".messaging.StreaksReminder" /> + <receiver android:name=".StreaksReminder" /> <provider android:name="androidx.core.content.FileProvider" diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/LogManager.kt @@ -2,6 +2,8 @@ package me.rhunk.snapenhance import android.util.Log import com.google.gson.GsonBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.data.FileType import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.logger.LogChannel @@ -70,33 +72,39 @@ class LogReader( } fun incrementLineCount() { - randomAccessFile.seek(randomAccessFile.length()) - startLineIndexes.add(randomAccessFile.filePointer + 1) - lineCount++ + synchronized(randomAccessFile) { + randomAccessFile.seek(randomAccessFile.length()) + startLineIndexes.add(randomAccessFile.filePointer + 1) + lineCount++ + } } private fun queryLineCount(): Int { - randomAccessFile.seek(0) - var lineCount = 0 - var lastPointer: Long - var line: String? - - while (randomAccessFile.also { - lastPointer = it.filePointer - }.readLine().also { line = it } != null) { - if (line?.startsWith('|') == true) { - lineCount++ - startLineIndexes.add(lastPointer + 1) + synchronized(randomAccessFile) { + randomAccessFile.seek(0) + var lineCount = 0 + var lastPointer: Long + var line: String? + + while (randomAccessFile.also { + lastPointer = it.filePointer + }.readLine().also { line = it } != null) { + if (line?.startsWith('|') == true) { + lineCount++ + startLineIndexes.add(lastPointer + 1) + } } - } - return lineCount + return lineCount + } } private fun getLine(index: Int): String? { if (index <= 0 || index > lineCount) return null - randomAccessFile.seek(startLineIndexes[index]) - return readLogLine()?.toString() + synchronized(randomAccessFile) { + randomAccessFile.seek(startLineIndexes[index]) + return readLogLine()?.toString() + } } fun getLogLine(index: Int): LogLine? { @@ -109,7 +117,6 @@ class LogManager( private val remoteSideContext: RemoteSideContext ): AbstractLogger(LogChannel.MANAGER) { companion object { - private const val TAG = "SnapEnhanceManager" private val LOG_LIFETIME = 24.hours } @@ -118,13 +125,13 @@ class LogManager( var lineAddListener = { _: LogLine -> } private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs") - private var logFile: File + private var logFile: File? = null private val uuidRegex by lazy { Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", RegexOption.MULTILINE) } private val contentUriRegex by lazy { Regex("content://[a-zA-Z0-9_\\-./]+") } private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") } - init { + fun init() { if (!logFolder.exists()) { logFolder.mkdirs() } @@ -153,7 +160,9 @@ class LogManager( tag = tag, message = anonymizedMessage ) - logFile.appendText("|$line\n", Charsets.UTF_8) + remoteSideContext.coroutineScope.launch(Dispatchers.IO) { + logFile?.appendText("|$line\n", Charsets.UTF_8) + } lineAddListener(line) Log.println(logLevel.priority, tag, anonymizedMessage) }.onFailure { @@ -172,8 +181,8 @@ class LogManager( val currentTime = System.currentTimeMillis() logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also { it.createNewFile() + remoteSideContext.sharedPreferences.edit().putString("log_file", it.absolutePath).putLong("last_created", currentTime).apply() } - remoteSideContext.sharedPreferences.edit().putString("log_file", logFile.absolutePath).putLong("last_created", currentTime).apply() } fun clearLogs() { @@ -201,7 +210,7 @@ class LogManager( zipOutputStream.close() } - fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also { + fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also { lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteAccountStorage.kt @@ -7,8 +7,10 @@ import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor class RemoteAccountStorage( private val context: RemoteSideContext ): AccountStorage.Stub() { - private val accountFolder = context.androidContext.filesDir.resolve("accounts").also { - if (!it.exists()) it.mkdirs() + private val accountFolder by lazy { + context.androidContext.filesDir.resolve("accounts").also { + if (!it.exists()) it.mkdirs() + } } override fun getAccounts(): Map<String, String> { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteSideContext.kt @@ -19,6 +19,8 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.bridge.BridgeService import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.bridge.types.BridgeFileType @@ -27,9 +29,8 @@ import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.config.ModConfig import me.rhunk.snapenhance.e2ee.E2EEImplementation -import me.rhunk.snapenhance.messaging.ModDatabase -import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.scripting.RemoteScriptManager +import me.rhunk.snapenhance.storage.AppDatabase import me.rhunk.snapenhance.task.TaskManager import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.data.InstallationSummary @@ -63,7 +64,7 @@ class RemoteSideContext( val translation = LocaleWrapper() val mappings = MappingsWrapper() val taskManager = TaskManager(this) - val modDatabase = ModDatabase(this) + val database = AppDatabase(this) val streaksReminder = StreaksReminder(this) val log = LogManager(this) val scriptManager = RemoteScriptManager(this) @@ -94,27 +95,32 @@ class RemoteSideContext( val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() } fun reload() { - log.verbose("Loading RemoteSideContext") runCatching { - config.loadFromContext(androidContext) - translation.apply { - userLocale = config.locale - loadFromContext(androidContext) - } - mappings.apply { - loadFromContext(androidContext) - init(androidContext) - } - taskManager.init() - modDatabase.init() - streaksReminder.init() - scriptManager.init() - messageLogger.init() - tracker.init() - config.root.messaging.messageLogger.takeIf { - it.globalState == true - }?.getAutoPurgeTime()?.let { - messageLogger.purgeAll(it) + runBlocking(Dispatchers.IO) { + log.init() + log.verbose("Loading RemoteSideContext") + config.loadFromContext(androidContext) + launch { + mappings.apply { + loadFromContext(androidContext) + init(androidContext) + } + } + translation.apply { + userLocale = config.locale + loadFromContext(androidContext) + } + database.init() + streaksReminder.init() + scriptManager.init() + launch { + taskManager.init() + config.root.messaging.messageLogger.takeIf { + it.globalState == true + }?.getAutoPurgeTime()?.let { + messageLogger.purgeAll(it) + } + } } }.onFailure { log.error("Failed to load RemoteSideContext", it) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/RemoteTracker.kt @@ -1,29 +1,29 @@ package me.rhunk.snapenhance import me.rhunk.snapenhance.bridge.logger.TrackerInterface +import me.rhunk.snapenhance.common.data.ScopedTrackerRule import me.rhunk.snapenhance.common.data.TrackerEventsResult import me.rhunk.snapenhance.common.data.TrackerRule import me.rhunk.snapenhance.common.data.TrackerRuleEvent import me.rhunk.snapenhance.common.util.toSerialized +import me.rhunk.snapenhance.storage.getRuleTrackerScopes +import me.rhunk.snapenhance.storage.getTrackerEvents class RemoteTracker( private val context: RemoteSideContext ): TrackerInterface.Stub() { - fun init() { - /*TrackerEventType.entries.forEach { eventType -> - val ruleId = context.modDatabase.addTrackerRule(TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, null, null) - context.modDatabase.addTrackerRuleEvent(ruleId, TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, eventType.key) - }*/ - } + fun init() {} override fun getTrackedEvents(eventType: String): String? { val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>() - context.modDatabase.getTrackerEvents(eventType).forEach { (event, rule) -> + context.database.getTrackerEvents(eventType).forEach { (event, rule) -> events.getOrPut(rule) { mutableListOf() }.add(event) } - return TrackerEventsResult(events).toSerialized() + return TrackerEventsResult(events.mapKeys { + ScopedTrackerRule(it.key, context.database.getRuleTrackerScopes(it.key.id)) + }).toSerialized() } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/StreaksReminder.kt @@ -0,0 +1,125 @@ +package me.rhunk.snapenhance + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.toBitmap +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.bridge.ForceStartActivity +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.getFriendStreaks +import me.rhunk.snapenhance.storage.getFriends +import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +class StreaksReminder( + private val remoteSideContext: RemoteSideContext? = null +): BroadcastReceiver() { + companion object { + private const val NOTIFICATION_CHANNEL_ID = "streaks" + } + + private fun getNotificationManager(context: Context) = context.getSystemService(NotificationManager::class.java).apply { + createNotificationChannel( + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Streaks", + NotificationManager.IMPORTANCE_HIGH + ) + ) + } + + override fun onReceive(ctx: Context, intent: Intent) { + val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx) + val streaksReminderConfig = remoteSideContext.config.root.streaksReminder + val sharedPreferences = remoteSideContext.sharedPreferences + + if (streaksReminderConfig.globalState != true) return + + val interval = streaksReminderConfig.interval.get().hours + val remainingHours = streaksReminderConfig.remainingHours.get() + + if (sharedPreferences.getLong("lastStreaksReminder", 0).milliseconds + interval - 10.minutes > System.currentTimeMillis().milliseconds) return + sharedPreferences.edit().putLong("lastStreaksReminder", System.currentTimeMillis()).apply() + + remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating( + AlarmManager.RTC_WAKEUP, 5000, interval.inWholeMilliseconds, + PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java), + PendingIntent.FLAG_IMMUTABLE) + ) + + val notifyFriendList = remoteSideContext.database.getFriends() + .associateBy { remoteSideContext.database.getFriendStreaks(it.userId) } + .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) } + + val notificationManager = getNotificationManager(ctx) + val streaksReminderTranslation = remoteSideContext.translation.getCategory("streaks_reminder") + + if (streaksReminderConfig.groupNotifications.get() && notifyFriendList.isNotEmpty()) { + notificationManager.notify(0, NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setGroup("streaks") + .setGroupSummary(true) + .setSmallIcon(R.drawable.streak_icon) + .build()) + } + + notifyFriendList.forEach { (streaks, friend) -> + remoteSideContext.coroutineScope.launch { + val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D) + val bitmojiImage = remoteSideContext.imageLoader.execute( + ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl) + ) + + val notificationBuilder = NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) + .setContentTitle(streaksReminderTranslation["notification_title"]) + .setContentText(streaksReminderTranslation.format("notification_text", + "friend" to (friend.displayName ?: friend.mutableUsername), + "hoursLeft" to (streaks?.hoursLeft() ?: 0).toString() + )) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setGroup("streaks") + .setContentIntent(PendingIntent.getActivity( + ctx, + 0, + Intent(ctx, ForceStartActivity::class.java).apply { + putExtra("streaks_notification_action", true) + }, + PendingIntent.FLAG_IMMUTABLE + )) + .apply { + setSmallIcon(R.drawable.streak_icon) + bitmojiImage.drawable?.let { + setLargeIcon(it.toBitmap()) + } + } + + if (streaksReminderConfig.groupNotifications.get()) { + notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + } + + notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply { + flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE + }) + } + } + } + + fun init() { + if (remoteSideContext == null) throw IllegalStateException("RemoteSideContext is null") + if (remoteSideContext.config.root.streaksReminder.globalState != true) return + + onReceive(remoteSideContext.androidContext, Intent()) + } + + fun dismissAllNotifications() = getNotificationManager(remoteSideContext!!.androidContext).cancelAll() +}+ \ 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 @@ -18,6 +18,7 @@ import me.rhunk.snapenhance.common.logger.LogLevel import me.rhunk.snapenhance.common.util.toParcelable import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.FFMpegProcessor +import me.rhunk.snapenhance.storage.* import me.rhunk.snapenhance.task.Task import me.rhunk.snapenhance.task.TaskType import java.io.File @@ -47,7 +48,7 @@ class BridgeService : Service() { fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) { runCatching { - val modDatabase = remoteSideContext.modDatabase + val modDatabase = remoteSideContext.database val syncedObject = when (scope) { SocialScope.FRIEND -> { if (updateOnly && modDatabase.getFriendInfo(id) == null) return @@ -194,24 +195,24 @@ class BridgeService : Service() { } override fun getRules(uuid: String): List<String> { - return remoteSideContext.modDatabase.getRules(uuid).map { it.key } + return remoteSideContext.database.getRules(uuid).map { it.key } } override fun getRuleIds(type: String): MutableList<String> { - return remoteSideContext.modDatabase.getRuleIds(type) + return remoteSideContext.database.getRuleIds(type) } override fun setRule(uuid: String, rule: String, state: Boolean) { - remoteSideContext.modDatabase.setRule(uuid, rule, state) + remoteSideContext.database.setRule(uuid, rule, state) } override fun sync(callback: SyncCallback) { syncCallback = callback measureTimeMillis { - remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId -> + remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId -> triggerScopeSync(SocialScope.FRIEND, friendId, true) } - remoteSideContext.modDatabase.getGroups().map { it.conversationId }.forEach { groupId -> + remoteSideContext.database.getGroups().map { it.conversationId }.forEach { groupId -> triggerScopeSync(SocialScope.GROUP, groupId, true) } }.also { @@ -229,7 +230,7 @@ class BridgeService : Service() { friends: List<String> ) { remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") - remoteSideContext.modDatabase.receiveMessagingDataCallback( + remoteSideContext.database.receiveMessagingDataCallback( friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }, groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) } ) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.download -import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapFactory diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/ModDatabase.kt @@ -1,405 +0,0 @@ -package me.rhunk.snapenhance.messaging - -import android.content.ContentValues -import android.database.sqlite.SQLiteDatabase -import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.common.data.* -import me.rhunk.snapenhance.common.scripting.type.ModuleInfo -import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper -import me.rhunk.snapenhance.common.util.ktx.getInteger -import me.rhunk.snapenhance.common.util.ktx.getLongOrNull -import me.rhunk.snapenhance.common.util.ktx.getStringOrNull -import java.util.concurrent.Executors -import kotlin.coroutines.suspendCoroutine - - -class ModDatabase( - private val context: RemoteSideContext, -) { - private val executor = Executors.newSingleThreadExecutor() - private lateinit var database: SQLiteDatabase - - var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> } - - fun executeAsync(block: () -> Unit) { - executor.execute { - runCatching { - block() - }.onFailure { - context.log.error("Failed to execute async block", it) - } - } - } - - fun init() { - database = context.androidContext.openOrCreateDatabase("main.db", 0, null) - SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( - "friends" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "userId CHAR(36) UNIQUE", - "dmConversationId VARCHAR(36)", - "displayName VARCHAR", - "mutableUsername VARCHAR", - "bitmojiId VARCHAR", - "selfieId VARCHAR" - ), - "groups" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "conversationId CHAR(36) UNIQUE", - "name VARCHAR", - "participantsCount INTEGER" - ), - "rules" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "type VARCHAR", - "targetUuid VARCHAR" - ), - "streaks" to listOf( - "id VARCHAR PRIMARY KEY", - "notify BOOLEAN", - "expirationTimestamp BIGINT", - "length INTEGER" - ), - "scripts" to listOf( - "name VARCHAR PRIMARY KEY", - "version VARCHAR NOT NULL", - "displayName VARCHAR", - "description VARCHAR", - "author VARCHAR NOT NULL", - "enabled BOOLEAN" - ), - "tracker_rules" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "flags INTEGER", - "conversation_id CHAR(36)", // nullable - "user_id CHAR(36)", // nullable - ), - "tracker_rules_events" to listOf( - "id INTEGER PRIMARY KEY AUTOINCREMENT", - "flags INTEGER", - "rule_id INTEGER", - "event_type VARCHAR", - ) - )) - } - - fun getGroups(): List<MessagingGroupInfo> { - return database.rawQuery("SELECT * FROM groups", null).use { cursor -> - val groups = mutableListOf<MessagingGroupInfo>() - while (cursor.moveToNext()) { - groups.add(MessagingGroupInfo.fromCursor(cursor)) - } - groups - } - } - - fun getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> { - return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor -> - val friends = mutableListOf<MessagingFriendInfo>() - while (cursor.moveToNext()) { - runCatching { - friends.add(MessagingFriendInfo.fromCursor(cursor)) - }.onFailure { - context.log.error("Failed to parse friend", it) - } - } - friends - } - } - - - fun syncGroupInfo(conversationInfo: MessagingGroupInfo) { - executeAsync { - try { - database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf( - conversationInfo.conversationId, - conversationInfo.name, - conversationInfo.participantsCount - )) - } catch (e: Exception) { - throw e - } - } - } - - fun syncFriend(friend: MessagingFriendInfo) { - executeAsync { - try { - database.execSQL( - "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", - arrayOf( - friend.userId, - friend.dmConversationId, - friend.displayName, - friend.mutableUsername, - friend.bitmojiId, - friend.selfieId - ) - ) - //sync streaks - friend.streaks?.takeIf { it.length > 0 }?.let { - val streaks = getFriendStreaks(friend.userId) - - database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf( - friend.userId, - streaks?.notify ?: true, - it.expirationTimestamp, - it.length - )) - } ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) - } catch (e: Exception) { - throw e - } - } - } - - fun getRules(targetUuid: String): List<MessagingRuleType> { - return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf( - targetUuid - )).use { cursor -> - val rules = mutableListOf<MessagingRuleType>() - while (cursor.moveToNext()) { - runCatching { - rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching) - }.onFailure { - context.log.error("Failed to parse rule", it) - } - } - rules - } - } - - fun setRule(targetUuid: String, type: String, enabled: Boolean) { - executeAsync { - if (enabled) { - database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf( - targetUuid, - type - )) - } else { - database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf( - targetUuid, - type - )) - } - } - } - - fun getFriendInfo(userId: String): MessagingFriendInfo? { - return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor -> - if (!cursor.moveToFirst()) return@use null - MessagingFriendInfo.fromCursor(cursor) - } - } - - fun findFriend(conversationId: String): MessagingFriendInfo? { - return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor -> - if (!cursor.moveToFirst()) return@use null - MessagingFriendInfo.fromCursor(cursor) - } - } - - fun deleteFriend(userId: String) { - executeAsync { - database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId)) - database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(userId)) - database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId)) - } - } - - fun deleteGroup(conversationId: String) { - executeAsync { - database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId)) - database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId)) - } - } - - fun getGroupInfo(conversationId: String): MessagingGroupInfo? { - return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor -> - if (!cursor.moveToFirst()) return@use null - MessagingGroupInfo.fromCursor(cursor) - } - } - - fun getFriendStreaks(userId: String): FriendStreaks? { - return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor -> - if (!cursor.moveToFirst()) return@use null - FriendStreaks( - notify = cursor.getInteger("notify") == 1, - expirationTimestamp = cursor.getLongOrNull("expirationTimestamp") ?: 0L, - length = cursor.getInteger("length") - ) - } - } - - fun setFriendStreaksNotify(userId: String, notify: Boolean) { - executeAsync { - database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf( - if (notify) 1 else 0, - userId - )) - } - } - - fun getRuleIds(type: String): MutableList<String> { - return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor -> - val ruleIds = mutableListOf<String>() - while (cursor.moveToNext()) { - ruleIds.add(cursor.getStringOrNull("targetUuid")!!) - } - ruleIds - } - } - - fun getScripts(): List<ModuleInfo> { - return database.rawQuery("SELECT * FROM scripts", null).use { cursor -> - val scripts = mutableListOf<ModuleInfo>() - while (cursor.moveToNext()) { - scripts.add( - ModuleInfo( - name = cursor.getStringOrNull("name")!!, - version = cursor.getStringOrNull("version")!!, - displayName = cursor.getStringOrNull("displayName"), - description = cursor.getStringOrNull("description"), - author = cursor.getStringOrNull("author"), - grantedPermissions = emptyList() - ) - ) - } - scripts - } - } - - fun setScriptEnabled(name: String, enabled: Boolean) { - executeAsync { - database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf( - if (enabled) 1 else 0, - name - )) - } - } - - fun isScriptEnabled(name: String): Boolean { - return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor -> - if (!cursor.moveToFirst()) return@use false - cursor.getInteger("enabled") == 1 - } - } - - fun syncScripts(availableScripts: List<ModuleInfo>) { - executeAsync { - val enabledScripts = getScripts() - val enabledScriptPaths = enabledScripts.map { it.name } - val availableScriptPaths = availableScripts.map { it.name } - - enabledScripts.forEach { script -> - if (!availableScriptPaths.contains(script.name)) { - database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name)) - } - } - - availableScripts.forEach { script -> - if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) { - database.execSQL( - "INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)", - arrayOf( - script.name, - script.version, - script.displayName, - script.description, - script.author, - 0 - ) - ) - } - } - } - } - - fun addTrackerRule(flags: Int, conversationId: String?, userId: String?): Int { - return runBlocking { - suspendCoroutine { continuation -> - executeAsync { - val id = database.insert("tracker_rules", null, ContentValues().apply { - put("flags", flags) - put("conversation_id", conversationId) - put("user_id", userId) - }) - continuation.resumeWith(Result.success(id.toInt())) - } - } - } - } - - fun addTrackerRuleEvent(ruleId: Int, flags: Int, eventType: String) { - executeAsync { - database.execSQL("INSERT INTO tracker_rules_events (flags, rule_id, event_type) VALUES (?, ?, ?)", arrayOf( - flags, - ruleId, - eventType - )) - } - } - - fun getTrackerRules(conversationId: String?, userId: String?): List<TrackerRule> { - val rules = mutableListOf<TrackerRule>() - - database.rawQuery("SELECT * FROM tracker_rules WHERE (conversation_id = ? OR conversation_id IS NULL) AND (user_id = ? OR user_id IS NULL)", arrayOf(conversationId, userId).filterNotNull().toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - rules.add( - TrackerRule( - id = cursor.getInteger("id"), - flags = cursor.getInteger("flags"), - conversationId = cursor.getStringOrNull("conversation_id"), - userId = cursor.getStringOrNull("user_id") - ) - ) - } - } - - return rules - } - - fun getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> { - val events = mutableListOf<TrackerRuleEvent>() - database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor -> - while (cursor.moveToNext()) { - events.add( - TrackerRuleEvent( - id = cursor.getInteger("id"), - flags = cursor.getInteger("flags"), - eventType = cursor.getStringOrNull("event_type") ?: continue - ) - ) - } - } - return events - } - - fun getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> { - val events = mutableMapOf<TrackerRuleEvent, TrackerRule>() - database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.conversation_id, tracker_rules.user_id " + - "FROM tracker_rules_events " + - "INNER JOIN tracker_rules " + - "ON tracker_rules_events.rule_id = tracker_rules.id " + - "WHERE event_type = ?", arrayOf(eventType) - ).use { cursor -> - while (cursor.moveToNext()) { - val trackerRule = TrackerRule( - id = -1, - flags = cursor.getInteger("flags"), - conversationId = cursor.getStringOrNull("conversation_id"), - userId = cursor.getStringOrNull("user_id") - ) - val trackerRuleEvent = TrackerRuleEvent( - id = cursor.getInteger("event_id"), - flags = cursor.getInteger("flags"), - eventType = cursor.getStringOrNull("event_type") ?: continue - ) - events[trackerRuleEvent] = trackerRule - } - } - return events - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/messaging/StreaksReminder.kt @@ -1,126 +0,0 @@ -package me.rhunk.snapenhance.messaging - -import android.app.AlarmManager -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.core.app.NotificationCompat -import androidx.core.graphics.drawable.toBitmap -import kotlinx.coroutines.launch -import me.rhunk.snapenhance.R -import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.SharedContextHolder -import me.rhunk.snapenhance.bridge.ForceStartActivity -import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie -import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.minutes - -class StreaksReminder( - private val remoteSideContext: RemoteSideContext? = null -): BroadcastReceiver() { - companion object { - private const val NOTIFICATION_CHANNEL_ID = "streaks" - } - - private fun getNotificationManager(context: Context) = context.getSystemService(NotificationManager::class.java).apply { - createNotificationChannel( - NotificationChannel( - NOTIFICATION_CHANNEL_ID, - "Streaks", - NotificationManager.IMPORTANCE_HIGH - ) - ) - } - - override fun onReceive(ctx: Context, intent: Intent) { - val remoteSideContext = this.remoteSideContext ?: SharedContextHolder.remote(ctx) - val streaksReminderConfig = remoteSideContext.config.root.streaksReminder - val sharedPreferences = remoteSideContext.sharedPreferences - - if (streaksReminderConfig.globalState != true) return - - val interval = streaksReminderConfig.interval.get().hours - val remainingHours = streaksReminderConfig.remainingHours.get() - - if (sharedPreferences.getLong("lastStreaksReminder", 0).milliseconds + interval - 10.minutes > System.currentTimeMillis().milliseconds) return - sharedPreferences.edit().putLong("lastStreaksReminder", System.currentTimeMillis()).apply() - - remoteSideContext.androidContext.getSystemService(AlarmManager::class.java).setRepeating( - AlarmManager.RTC_WAKEUP, 5000, interval.inWholeMilliseconds, - PendingIntent.getBroadcast(remoteSideContext.androidContext, 0, Intent(remoteSideContext.androidContext, StreaksReminder::class.java), - PendingIntent.FLAG_IMMUTABLE) - ) - - val notifyFriendList = remoteSideContext.modDatabase.getFriends() - .associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) } - .filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) } - - val notificationManager = getNotificationManager(ctx) - val streaksReminderTranslation = remoteSideContext.translation.getCategory("streaks_reminder") - - if (streaksReminderConfig.groupNotifications.get() && notifyFriendList.isNotEmpty()) { - notificationManager.notify(0, NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - .setGroup("streaks") - .setGroupSummary(true) - .setSmallIcon(R.drawable.streak_icon) - .build()) - } - - notifyFriendList.forEach { (streaks, friend) -> - remoteSideContext.coroutineScope.launch { - val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D) - val bitmojiImage = remoteSideContext.imageLoader.execute( - ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl) - ) - - val notificationBuilder = NotificationCompat.Builder(ctx, NOTIFICATION_CHANNEL_ID) - .setContentTitle(streaksReminderTranslation["notification_title"]) - .setContentText(streaksReminderTranslation.format("notification_text", - "friend" to (friend.displayName ?: friend.mutableUsername), - "hoursLeft" to (streaks?.hoursLeft() ?: 0).toString() - )) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setAutoCancel(true) - .setGroup("streaks") - .setContentIntent(PendingIntent.getActivity( - ctx, - 0, - Intent(ctx, ForceStartActivity::class.java).apply { - putExtra("streaks_notification_action", true) - }, - PendingIntent.FLAG_IMMUTABLE - )) - .apply { - setSmallIcon(R.drawable.streak_icon) - bitmojiImage.drawable?.let { - setLargeIcon(it.toBitmap()) - } - } - - if (streaksReminderConfig.groupNotifications.get()) { - notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - } - - notificationManager.notify(friend.userId.hashCode(), notificationBuilder.build().apply { - flags = NotificationCompat.FLAG_ONLY_ALERT_ONCE - }) - } - } - } - - fun init() { - if (remoteSideContext == null) throw IllegalStateException("RemoteSideContext is null") - if (remoteSideContext.config.root.streaksReminder.globalState != true) return - - onReceive(remoteSideContext.androidContext, Intent()) - } - - fun dismissAllNotifications() = getNotificationManager(remoteSideContext!!.androidContext).cancelAll() -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/AutoReloadHandler.kt @@ -15,23 +15,27 @@ class AutoReloadHandler( private val lastModifiedMap = mutableMapOf<Uri, Long>() fun addFile(file: DocumentFile) { - files.add(file) - lastModifiedMap[file.uri] = file.lastModified() + synchronized(lastModifiedMap) { + files.add(file) + lastModifiedMap[file.uri] = file.lastModified() + } } fun start() { coroutineScope.launch(Dispatchers.IO) { while (true) { - files.forEach { file -> - val lastModified = lastModifiedMap[file.uri] ?: return@forEach - runCatching { - val newLastModified = file.lastModified() - if (newLastModified > lastModified) { - lastModifiedMap[file.uri] = newLastModified - onReload(file) + synchronized(lastModifiedMap) { + files.forEach { file -> + val lastModified = lastModifiedMap[file.uri] ?: return@forEach + runCatching { + val newLastModified = file.lastModified() + if (newLastModified > lastModified) { + lastModifiedMap[file.uri] = newLastModified + onReload(file) + } + }.onFailure { + it.printStackTrace() } - }.onFailure { - it.printStackTrace() } } delay(1000) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/scripting/RemoteScriptManager.kt @@ -17,6 +17,10 @@ import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor import me.rhunk.snapenhance.scripting.impl.IPCListeners import me.rhunk.snapenhance.scripting.impl.ManagerIPC import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig +import me.rhunk.snapenhance.storage.isScriptEnabled +import me.rhunk.snapenhance.storage.syncScripts +import okhttp3.OkHttpClient +import okhttp3.Request import java.io.File import java.io.InputStream import kotlin.system.exitProcess @@ -28,6 +32,10 @@ class RemoteScriptManager( scripting = this@RemoteScriptManager } + private val okHttpClient by lazy { + OkHttpClient.Builder().build() + } + private var autoReloadListener: AutoReloadListener? = null private val autoReloadHandler by lazy { AutoReloadHandler(context.coroutineScope) { @@ -49,6 +57,7 @@ class RemoteScriptManager( private val ipcListeners = IPCListeners() fun sync() { + cachedModuleInfo.clear() getScriptFileNames().forEach { name -> runCatching { getScriptInputStream(name) { stream -> @@ -63,7 +72,7 @@ class RemoteScriptManager( } } - context.modDatabase.syncScripts(cachedModuleInfo.values.toList()) + context.database.syncScripts(cachedModuleInfo.values.toList()) } fun init() { @@ -77,7 +86,11 @@ class RemoteScriptManager( sync() enabledScripts.forEach { name -> - loadScript(name) + runCatching { + loadScript(name) + }.onFailure { + context.log.error("Failed to load script $name", it) + } } } @@ -87,10 +100,10 @@ class RemoteScriptManager( fun loadScript(path: String) { val content = getScriptContent(path) ?: return + runtime.load(path, content) if (context.config.root.scripting.autoReload.getNullable() != null) { autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return) } - runtime.load(path, content) } fun unloadScript(scriptPath: String) { @@ -119,10 +132,38 @@ class RemoteScriptManager( return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! } } + fun importFromUrl( + url: String + ): ModuleInfo { + val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() + if (!response.isSuccessful) { + throw Exception("Failed to fetch script. Code: ${response.code}") + } + response.body.byteStream().use { inputStream -> + val bufferedInputStream = inputStream.buffered() + bufferedInputStream.mark(0) + val moduleInfo = runtime.readModuleInfo(bufferedInputStream.bufferedReader()) + bufferedInputStream.reset() + + val scriptPath = moduleInfo.name + ".js" + val scriptFile = getScriptsFolder()?.findFile(scriptPath) ?: getScriptsFolder()?.createFile("text/javascript", scriptPath) + ?: throw Exception("Failed to create script file") + + context.androidContext.contentResolver.openOutputStream(scriptFile.uri)?.use { output -> + bufferedInputStream.copyTo(output) + } + + sync() + loadScript(scriptPath) + runtime.removeModule(scriptPath) + return moduleInfo + } + } + override fun getEnabledScripts(): List<String> { return runCatching { getScriptFileNames().filter { - context.modDatabase.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false) + context.database.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false) } }.onFailure { context.log.error("Failed to get enabled scripts", it) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/AppDatabase.kt @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.storage + +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + + +class AppDatabase( + val context: RemoteSideContext, +) { + val executor: ExecutorService = Executors.newSingleThreadExecutor() + lateinit var database: SQLiteDatabase + + var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> } + + fun executeAsync(block: () -> Unit) { + executor.execute { + runCatching { + block() + }.onFailure { + context.log.error("Failed to execute async block", it) + } + } + } + + fun init() { + database = context.androidContext.openOrCreateDatabase("main.db", 0, null) + SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( + "friends" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "userId CHAR(36) UNIQUE", + "dmConversationId VARCHAR(36)", + "displayName VARCHAR", + "mutableUsername VARCHAR", + "bitmojiId VARCHAR", + "selfieId VARCHAR" + ), + "groups" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "conversationId CHAR(36) UNIQUE", + "name VARCHAR", + "participantsCount INTEGER" + ), + "rules" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "type VARCHAR", + "targetUuid VARCHAR" + ), + "streaks" to listOf( + "id VARCHAR PRIMARY KEY", + "notify BOOLEAN", + "expirationTimestamp BIGINT", + "length INTEGER" + ), + "scripts" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "name VARCHAR NOT NULL", + "version VARCHAR NOT NULL", + "displayName VARCHAR", + "description VARCHAR", + "author VARCHAR NOT NULL", + "enabled BOOLEAN" + ), + "tracker_rules" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "enabled BOOLEAN DEFAULT 1", + "name VARCHAR", + ), + "tracker_scopes" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "rule_id INTEGER", + "scope_type VARCHAR", + "scope_id CHAR(36)" + ), + "tracker_rules_events" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "rule_id INTEGER", + "flags INTEGER DEFAULT 1", + "event_type VARCHAR", + "params TEXT", + "actions TEXT" + ), + "quick_tiles" to listOf( + "key VARCHAR PRIMARY KEY", + "position INTEGER", + ) + )) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt @@ -0,0 +1,181 @@ +package me.rhunk.snapenhance.storage + +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo +import me.rhunk.snapenhance.common.data.MessagingRuleType +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getLongOrNull +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getGroups(): List<MessagingGroupInfo> { + return database.rawQuery("SELECT * FROM groups", null).use { cursor -> + val groups = mutableListOf<MessagingGroupInfo>() + while (cursor.moveToNext()) { + groups.add(MessagingGroupInfo.fromCursor(cursor)) + } + groups + } +} + +fun AppDatabase.getFriends(descOrder: Boolean = false): List<MessagingFriendInfo> { + return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id ORDER BY id ${if (descOrder) "DESC" else "ASC"}", null).use { cursor -> + val friends = mutableListOf<MessagingFriendInfo>() + while (cursor.moveToNext()) { + runCatching { + friends.add(MessagingFriendInfo.fromCursor(cursor)) + }.onFailure { + context.log.error("Failed to parse friend", it) + } + } + friends + } +} + + +fun AppDatabase.syncGroupInfo(conversationInfo: MessagingGroupInfo) { + executeAsync { + try { + database.execSQL("INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", arrayOf( + conversationInfo.conversationId, + conversationInfo.name, + conversationInfo.participantsCount + )) + } catch (e: Exception) { + throw e + } + } +} + +fun AppDatabase.syncFriend(friend: MessagingFriendInfo) { + executeAsync { + try { + database.execSQL( + "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf( + friend.userId, + friend.dmConversationId, + friend.displayName, + friend.mutableUsername, + friend.bitmojiId, + friend.selfieId + ) + ) + //sync streaks + friend.streaks?.takeIf { it.length > 0 }?.let { + val streaks = getFriendStreaks(friend.userId) + + database.execSQL("INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", arrayOf( + friend.userId, + streaks?.notify ?: true, + it.expirationTimestamp, + it.length + )) + } ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) + } catch (e: Exception) { + throw e + } + } +} + + + +fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> { + return database.rawQuery("SELECT type FROM rules WHERE targetUuid = ?", arrayOf( + targetUuid + )).use { cursor -> + val rules = mutableListOf<MessagingRuleType>() + while (cursor.moveToNext()) { + runCatching { + rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!) ?: return@runCatching) + }.onFailure { + context.log.error("Failed to parse rule", it) + } + } + rules + } +} + +fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) { + executeAsync { + if (enabled) { + database.execSQL("INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)", arrayOf( + targetUuid, + type + )) + } else { + database.execSQL("DELETE FROM rules WHERE targetUuid = ? AND type = ?", arrayOf( + targetUuid, + type + )) + } + } +} + +fun AppDatabase.getFriendInfo(userId: String): MessagingFriendInfo? { + return database.rawQuery("SELECT * FROM friends LEFT OUTER JOIN streaks ON friends.userId = streaks.id WHERE userId = ?", arrayOf(userId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingFriendInfo.fromCursor(cursor) + } +} + +fun AppDatabase.findFriend(conversationId: String): MessagingFriendInfo? { + return database.rawQuery("SELECT * FROM friends WHERE dmConversationId = ?", arrayOf(conversationId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingFriendInfo.fromCursor(cursor) + } +} + +fun AppDatabase.deleteFriend(userId: String) { + executeAsync { + database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId)) + database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(userId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId)) + } +} + +fun AppDatabase.deleteGroup(conversationId: String) { + executeAsync { + database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId)) + } +} + +fun AppDatabase.getGroupInfo(conversationId: String): MessagingGroupInfo? { + return database.rawQuery("SELECT * FROM groups WHERE conversationId = ?", arrayOf(conversationId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + MessagingGroupInfo.fromCursor(cursor) + } +} + +fun AppDatabase.getFriendStreaks(userId: String): FriendStreaks? { + return database.rawQuery("SELECT * FROM streaks WHERE id = ?", arrayOf(userId)).use { cursor -> + if (!cursor.moveToFirst()) return@use null + FriendStreaks( + notify = cursor.getInteger("notify") == 1, + expirationTimestamp = cursor.getLongOrNull("expirationTimestamp") ?: 0L, + length = cursor.getInteger("length") + ) + } +} + +fun AppDatabase.setFriendStreaksNotify(userId: String, notify: Boolean) { + executeAsync { + database.execSQL("UPDATE streaks SET notify = ? WHERE id = ?", arrayOf( + if (notify) 1 else 0, + userId + )) + } +} + +fun AppDatabase.getRuleIds(type: String): MutableList<String> { + return database.rawQuery("SELECT targetUuid FROM rules WHERE type = ?", arrayOf(type)).use { cursor -> + val ruleIds = mutableListOf<String>() + while (cursor.moveToNext()) { + ruleIds.add(cursor.getStringOrNull("targetUuid")!!) + } + ruleIds + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/QuickTiles.kt @@ -0,0 +1,26 @@ +package me.rhunk.snapenhance.storage + +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getQuickTiles(): List<String> { + return database.rawQuery("SELECT `key` FROM quick_tiles ORDER BY position ASC", null).use { cursor -> + val keys = mutableListOf<String>() + while (cursor.moveToNext()) { + keys.add(cursor.getStringOrNull("key") ?: continue) + } + keys + } +} + +fun AppDatabase.setQuickTiles(keys: List<String>) { + executeAsync { + database.execSQL("DELETE FROM quick_tiles") + keys.forEachIndexed { index, key -> + database.execSQL("INSERT INTO quick_tiles (`key`, position) VALUES (?, ?)", arrayOf( + key, + index + )) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Scripting.kt @@ -0,0 +1,73 @@ +package me.rhunk.snapenhance.storage + +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.scripting.type.ModuleInfo +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull + + +fun AppDatabase.getScripts(): List<ModuleInfo> { + return database.rawQuery("SELECT * FROM scripts ORDER BY id DESC", null).use { cursor -> + val scripts = mutableListOf<ModuleInfo>() + while (cursor.moveToNext()) { + scripts.add( + ModuleInfo( + name = cursor.getStringOrNull("name")!!, + version = cursor.getStringOrNull("version")!!, + displayName = cursor.getStringOrNull("displayName"), + description = cursor.getStringOrNull("description"), + author = cursor.getStringOrNull("author"), + grantedPermissions = emptyList() + ) + ) + } + scripts + } +} + +fun AppDatabase.setScriptEnabled(name: String, enabled: Boolean) { + executeAsync { + database.execSQL("UPDATE scripts SET enabled = ? WHERE name = ?", arrayOf( + if (enabled) 1 else 0, + name + )) + } +} + +fun AppDatabase.isScriptEnabled(name: String): Boolean { + return database.rawQuery("SELECT enabled FROM scripts WHERE name = ?", arrayOf(name)).use { cursor -> + if (!cursor.moveToFirst()) return@use false + cursor.getInteger("enabled") == 1 + } +} + +fun AppDatabase.syncScripts(availableScripts: List<ModuleInfo>) { + runBlocking(executor.asCoroutineDispatcher()) { + val enabledScripts = getScripts() + val enabledScriptPaths = enabledScripts.map { it.name } + val availableScriptPaths = availableScripts.map { it.name } + + enabledScripts.forEach { script -> + if (!availableScriptPaths.contains(script.name)) { + database.execSQL("DELETE FROM scripts WHERE name = ?", arrayOf(script.name)) + } + } + + availableScripts.forEach { script -> + if (!enabledScriptPaths.contains(script.name) || script != enabledScripts.find { it.name == script.name }) { + database.execSQL( + "INSERT OR REPLACE INTO scripts (name, version, displayName, description, author, enabled) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf( + script.name, + script.version, + script.displayName, + script.description, + script.author, + 0 + ) + ) + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt @@ -0,0 +1,197 @@ +package me.rhunk.snapenhance.storage + +import android.content.ContentValues +import com.google.gson.JsonArray +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.common.data.TrackerRule +import me.rhunk.snapenhance.common.data.TrackerRuleAction +import me.rhunk.snapenhance.common.data.TrackerRuleActionParams +import me.rhunk.snapenhance.common.data.TrackerRuleEvent +import me.rhunk.snapenhance.common.data.TrackerScopeType +import me.rhunk.snapenhance.common.util.ktx.getInteger +import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import kotlin.coroutines.suspendCoroutine + + +fun AppDatabase.clearTrackerRules() { + runBlocking { + suspendCoroutine { continuation -> + executeAsync { + database.execSQL("DELETE FROM tracker_rules") + database.execSQL("DELETE FROM tracker_rules_events") + continuation.resumeWith(Result.success(Unit)) + } + } + } +} + +fun AppDatabase.deleteTrackerRule(ruleId: Int) { + executeAsync { + database.execSQL("DELETE FROM tracker_rules WHERE id = ?", arrayOf(ruleId)) + database.execSQL("DELETE FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId)) + } +} + +fun AppDatabase.newTrackerRule(name: String = "Custom Rule"): Int { + return runBlocking { + suspendCoroutine { continuation -> + executeAsync { + val id = database.insert("tracker_rules", null, ContentValues().apply { + put("name", name) + }) + continuation.resumeWith(Result.success(id.toInt())) + } + } + } +} + +fun AppDatabase.addOrUpdateTrackerRuleEvent( + ruleEventId: Int? = null, + ruleId: Int? = null, + eventType: String? = null, + params: TrackerRuleActionParams, + actions: List<TrackerRuleAction> +): Int? { + return runBlocking { + suspendCoroutine { continuation -> + executeAsync { + val id = if (ruleEventId != null) { + database.execSQL("UPDATE tracker_rules_events SET params = ?, actions = ? WHERE id = ?", arrayOf( + context.gson.toJson(params), + context.gson.toJson(actions.map { it.key }), + ruleEventId + )) + ruleEventId + } else { + database.insert("tracker_rules_events", null, ContentValues().apply { + put("rule_id", ruleId) + put("event_type", eventType) + put("params", context.gson.toJson(params)) + put("actions", context.gson.toJson(actions.map { it.key })) + }).toInt() + } + continuation.resumeWith(Result.success(id)) + } + } + } +} + +fun AppDatabase.deleteTrackerRuleEvent(eventId: Int) { + executeAsync { + database.execSQL("DELETE FROM tracker_rules_events WHERE id = ?", arrayOf(eventId)) + } +} + +fun AppDatabase.getTrackerRulesDesc(): List<TrackerRule> { + val rules = mutableListOf<TrackerRule>() + + database.rawQuery("SELECT * FROM tracker_rules ORDER BY id DESC", null).use { cursor -> + while (cursor.moveToNext()) { + rules.add( + TrackerRule( + id = cursor.getInteger("id"), + enabled = cursor.getInteger("enabled") == 1, + name = cursor.getStringOrNull("name") ?: "", + ) + ) + } + } + + return rules +} + +fun AppDatabase.getTrackerRule(ruleId: Int): TrackerRule? { + return database.rawQuery("SELECT * FROM tracker_rules WHERE id = ?", arrayOf(ruleId.toString())).use { cursor -> + if (!cursor.moveToFirst()) return@use null + TrackerRule( + id = cursor.getInteger("id"), + enabled = cursor.getInteger("enabled") == 1, + name = cursor.getStringOrNull("name") ?: "", + ) + } +} + +fun AppDatabase.setTrackerRuleName(ruleId: Int, name: String) { + executeAsync { + database.execSQL("UPDATE tracker_rules SET name = ? WHERE id = ?", arrayOf(name, ruleId)) + } +} + +fun AppDatabase.setTrackerRuleState(ruleId: Int, enabled: Boolean) { + executeAsync { + database.execSQL("UPDATE tracker_rules SET enabled = ? WHERE id = ?", arrayOf(if (enabled) 1 else 0, ruleId)) + } +} + +fun AppDatabase.getTrackerEvents(ruleId: Int): List<TrackerRuleEvent> { + val events = mutableListOf<TrackerRuleEvent>() + database.rawQuery("SELECT * FROM tracker_rules_events WHERE rule_id = ?", arrayOf(ruleId.toString())).use { cursor -> + while (cursor.moveToNext()) { + events.add( + TrackerRuleEvent( + id = cursor.getInteger("id"), + eventType = cursor.getStringOrNull("event_type") ?: continue, + enabled = cursor.getInteger("flags") == 1, + params = context.gson.fromJson(cursor.getStringOrNull("params") ?: "{}", TrackerRuleActionParams::class.java), + actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull { + TrackerRuleAction.fromString(it.asString) + } + ) + ) + } + } + return events +} + +fun AppDatabase.getTrackerEvents(eventType: String): Map<TrackerRuleEvent, TrackerRule> { + val events = mutableMapOf<TrackerRuleEvent, TrackerRule>() + database.rawQuery("SELECT tracker_rules_events.id as event_id, tracker_rules_events.params as event_params," + + "tracker_rules_events.actions, tracker_rules_events.flags, tracker_rules_events.event_type, tracker_rules.name, tracker_rules.id as rule_id " + + "FROM tracker_rules_events " + + "INNER JOIN tracker_rules " + + "ON tracker_rules_events.rule_id = tracker_rules.id " + + "WHERE event_type = ? AND tracker_rules.enabled = 1", arrayOf(eventType) + ).use { cursor -> + while (cursor.moveToNext()) { + val trackerRule = TrackerRule( + id = cursor.getInteger("rule_id"), + enabled = true, + name = cursor.getStringOrNull("name") ?: "", + ) + val trackerRuleEvent = TrackerRuleEvent( + id = cursor.getInteger("event_id"), + eventType = cursor.getStringOrNull("event_type") ?: continue, + enabled = cursor.getInteger("flags") == 1, + params = context.gson.fromJson(cursor.getStringOrNull("event_params") ?: "{}", TrackerRuleActionParams::class.java), + actions = context.gson.fromJson(cursor.getStringOrNull("actions") ?: "[]", JsonArray::class.java).mapNotNull { + TrackerRuleAction.fromString(it.asString) + } + ) + events[trackerRuleEvent] = trackerRule + } + } + return events +} + +fun AppDatabase.setRuleTrackerScopes(ruleId: Int, type: TrackerScopeType, scopes: List<String>) { + executeAsync { + database.execSQL("DELETE FROM tracker_scopes WHERE rule_id = ?", arrayOf(ruleId)) + scopes.forEach { scopeId -> + database.execSQL("INSERT INTO tracker_scopes (rule_id, scope_type, scope_id) VALUES (?, ?, ?)", arrayOf( + ruleId, + type.key, + scopeId + )) + } + } +} + +fun AppDatabase.getRuleTrackerScopes(ruleId: Int, limit: Int = Int.MAX_VALUE): Map<String, TrackerScopeType> { + val scopes = mutableMapOf<String, TrackerScopeType>() + database.rawQuery("SELECT * FROM tracker_scopes WHERE rule_id = ? LIMIT ?", arrayOf(ruleId.toString(), limit.toString())).use { cursor -> + while (cursor.moveToNext()) { + scopes[cursor.getStringOrNull("scope_id") ?: continue] = TrackerScopeType.entries.find { it.key == cursor.getStringOrNull("scope_type") } ?: continue + } + } + return scopes +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Navigation.kt @@ -78,7 +78,7 @@ class Navigation( val currentRoute = routes.getCurrentRoute(navBackStackEntry) primaryRoutes.forEach { route -> NavigationBarItem( - alwaysShowLabel = false, + alwaysShowLabel = true, icon = { Icon(imageVector = route.routeInfo.icon, contentDescription = null) }, @@ -88,7 +88,7 @@ class Navigation( softWrap = false, fontSize = 12.sp, modifier = Modifier.wrapContentWidth(unbounded = true), - text = if (currentRoute == route) context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] else "", + text = remember(context.translation.loadedLocale) { context.translation["manager.routes.${route.routeInfo.key.substringBefore("/")}"] }, ) }, selected = currentRoute == route, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -15,7 +15,6 @@ import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import me.rhunk.snapenhance.RemoteSideContext -import me.rhunk.snapenhance.ui.manager.pages.FriendTrackerManagerRoot import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot import me.rhunk.snapenhance.ui.manager.pages.TasksRoot import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot @@ -27,6 +26,8 @@ import me.rhunk.snapenhance.ui.manager.pages.social.LoggedStories import me.rhunk.snapenhance.ui.manager.pages.social.ManageScope import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview import me.rhunk.snapenhance.ui.manager.pages.social.SocialRoot +import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule +import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot data class RouteInfo( @@ -55,6 +56,7 @@ class Routes( val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home) val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home) val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).parent(home) + val editRule = route(RouteInfo("edit_rule/?rule_id={rule_id}"), EditRule()) val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot()) val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/FriendTrackerManagerRoot.kt @@ -1,330 +0,0 @@ -package me.rhunk.snapenhance.ui.manager.pages - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.DeleteOutline -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.PopupProperties -import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog -import me.rhunk.snapenhance.common.data.TrackerEventType -import me.rhunk.snapenhance.common.data.TrackerRule -import me.rhunk.snapenhance.common.data.TrackerRuleEvent -import me.rhunk.snapenhance.ui.manager.Routes -import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset -import java.text.DateFormat - - -@OptIn(ExperimentalFoundationApi::class) -class FriendTrackerManagerRoot : Routes.Route() { - enum class FilterType { - CONVERSATION, USERNAME, EVENT - } - - private val titles = listOf("Logs", "Config Rules") - private var currentPage by mutableIntStateOf(0) - - override val floatingActionButton: @Composable () -> Unit = { - if (currentPage == 1) { - ExtendedFloatingActionButton( - icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") }, - expanded = false, - text = {}, - onClick = {} - ) - } - } - - @OptIn(ExperimentalMaterial3Api::class) - @Composable - private fun LogsTab() { - val coroutineScope = rememberCoroutineScope() - - val logs = remember { mutableStateListOf<TrackerLog>() } - var lastTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } - var filterType by remember { mutableStateOf(FilterType.USERNAME) } - - var filter by remember { mutableStateOf("") } - var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } - - suspend fun loadNewLogs() { - withContext(Dispatchers.IO) { - logs.addAll(context.messageLogger.getLogs(lastTimestamp, filter = { - when (filterType) { - FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true) - FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup) - FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true) - } - }).apply { - lastTimestamp = minOfOrNull { it.timestamp } ?: lastTimestamp - }) - } - } - - suspend fun resetAndLoadLogs() { - logs.clear() - lastTimestamp = Long.MAX_VALUE - loadNewLogs() - } - - Column( - modifier = Modifier.fillMaxSize() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - var showAutoComplete by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = showAutoComplete, onExpandedChange = { showAutoComplete = it }) { - TextField( - value = filter, - onValueChange = { - filter = it - coroutineScope.launch { - searchTimeoutJob?.cancel() - searchTimeoutJob = coroutineScope.launch { - delay(200) - showAutoComplete = true - resetAndLoadLogs() - } - } - }, - placeholder = { Text("Search") }, - maxLines = 1, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent - ), - modifier = Modifier - .weight(1F) - .menuAnchor() - .padding(8.dp) - ) - - DropdownMenu(expanded = showAutoComplete, onDismissRequest = { - showAutoComplete = false - }, properties = PopupProperties(focusable = false)) { - val suggestedEntries = remember(filter) { - mutableStateListOf<String>() - } - - LaunchedEffect(filter) { - suggestedEntries.addAll(when (filterType) { - FilterType.USERNAME -> context.messageLogger.findUsername(filter) - FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter) - FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key } - }.take(5)) - } - - suggestedEntries.forEach { entry -> - DropdownMenuItem(onClick = { - filter = entry - coroutineScope.launch { - resetAndLoadLogs() - } - showAutoComplete = false - }, text = { - Text(entry) - }) - } - } - } - - var dropDownExpanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = dropDownExpanded, onExpandedChange = { dropDownExpanded = it }) { - ElevatedCard( - modifier = Modifier.menuAnchor() - ) { - Text("Filter " + filterType.name, modifier = Modifier.padding(8.dp)) - } - DropdownMenu(expanded = dropDownExpanded, onDismissRequest = { - dropDownExpanded = false - }) { - FilterType.entries.forEach { type -> - DropdownMenuItem(onClick = { - filterType = type - dropDownExpanded = false - coroutineScope.launch { - resetAndLoadLogs() - } - }, text = { - Text(type.name) - }) - } - } - } - } - - LazyColumn( - modifier = Modifier.weight(1f) - ) { - item { - if (logs.isEmpty()) { - Text("No logs found", modifier = Modifier.padding(16.dp).fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light) - } - } - items(logs) { log -> - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Column( - modifier = Modifier - .weight(1f) - ) { - Text(log.username + " " + log.eventType + " in " + log.conversationTitle) - Text( - DateFormat.getDateTimeInstance().format(log.timestamp), - fontSize = 12.sp, - fontWeight = FontWeight.Light - ) - } - - OutlinedIconButton( - onClick = { - - } - ) { - Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") - } - } - } - } - item { - Spacer(modifier = Modifier.height(16.dp)) - - LaunchedEffect(lastTimestamp) { - loadNewLogs() - } - } - } - } - - } - - @Composable - @OptIn(ExperimentalLayoutApi::class) - private fun ConfigRulesTab() { - val rules = remember { mutableStateListOf<TrackerRule>() } - - Column( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.weight(1f) - ) { - items(rules) { rule -> - val events = remember(rule.id) { - mutableStateListOf<TrackerRuleEvent>() - } - - LaunchedEffect(rule.id) { - withContext(Dispatchers.IO) { - events.addAll(context.modDatabase.getTrackerEvents(rule.id)) - } - } - - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .padding(5.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text("Rule: ${rule.id} - conversationId: ${rule.conversationId?.let { "present" } ?: "none" } - userId: ${rule.userId?.let { "present" } ?: "none"}") - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { - events.forEach { event -> - Text("${event.eventType} - ${event.flags}") - } - } - } - } - } - } - } - - LaunchedEffect(Unit) { - rules.addAll(context.modDatabase.getTrackerRules(null, null)) - } - } - - - @OptIn(ExperimentalFoundationApi::class) - override val content: @Composable (NavBackStackEntry) -> Unit = { - val coroutineScope = rememberCoroutineScope() - val pagerState = rememberPagerState { titles.size } - currentPage = pagerState.currentPage - - Column { - TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset( - pagerState = pagerState, - tabPositions = tabPositions - ) - ) - }) { - titles.forEachIndexed { index, title -> - Tab( - selected = pagerState.currentPage == index, - onClick = { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { - Text( - text = title, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - - HorizontalPager( - modifier = Modifier.weight(1f), - state = pagerState - ) { page -> - when (page) { - 0 -> LogsTab() - 1 -> ConfigRulesTab() - } - } - } - } -}- \ 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 @@ -17,9 +17,11 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers @@ -33,14 +35,19 @@ import me.rhunk.snapenhance.common.data.download.DownloadMetadata import me.rhunk.snapenhance.common.data.download.DownloadRequest import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.storage.findFriend +import me.rhunk.snapenhance.storage.getFriendInfo +import me.rhunk.snapenhance.storage.getGroupInfo import me.rhunk.snapenhance.ui.manager.Routes import java.nio.ByteBuffer +import java.text.DateFormat import java.util.UUID import kotlin.math.absoluteValue @@ -114,7 +121,7 @@ class LoggerHistoryRoot : Routes.Route() { ) { Row( modifier = Modifier - .padding(4.dp) + .padding(8.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { @@ -123,7 +130,7 @@ class LoggerHistoryRoot : Routes.Route() { LaunchedEffect(Unit, message) { runCatching { decodeMessage(message) { senderId, contentType, messageReader, attachments -> - val senderUsername = senderId?.let { context.modDatabase.getFriendInfo(it)?.mutableUsername } ?: translation["unknown_sender"] + val senderUsername = senderId?.let { context.database.getFriendInfo(it)?.mutableUsername } ?: translation["unknown_sender"] @Composable fun ContentHeader() { @@ -141,6 +148,26 @@ class LoggerHistoryRoot : Routes.Route() { context.androidContext.copyToClipboard(content) }) }) + + val edits by rememberAsyncMutableState(defaultValue = emptyList()) { + loggerWrapper.getMessageEdits(selectedConversation!!, message.messageId) + } + edits.forEach { messageEdit -> + val date = remember { + DateFormat.getDateTimeInstance().format(messageEdit.timestamp) + } + Text( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { + context.androidContext.copyToClipboard(messageEdit.messageText) + }) + }.fillMaxWidth().padding(start = 4.dp), + text = messageEdit.messageText + " (edited at $date)", + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + fontSize = 12.sp + ) + } ContentHeader() } } @@ -209,9 +236,9 @@ class LoggerHistoryRoot : Routes.Route() { ) { fun formatConversationId(conversationId: String?): String? { if (conversationId == null) return null - return context.modDatabase.getGroupInfo(conversationId)?.name?.let { + return context.database.getGroupInfo(conversationId)?.name?.let { translation.format("list_group_format", "name" to it) - } ?: context.modDatabase.findFriend(conversationId)?.let { + } ?: context.database.findFriend(conversationId)?.let { translation.format("list_friend_format", "name" to (it.displayName?.let { name -> "$name (${it.mutableUsername})" } ?: it.mutableUsername)) } ?: conversationId } @@ -225,13 +252,8 @@ class LoggerHistoryRoot : Routes.Route() { .fillMaxWidth() ) - val conversations = remember { mutableStateListOf<String>() } - - LaunchedEffect(Unit) { - conversations.clear() - withContext(Dispatchers.IO) { - conversations.addAll(loggerWrapper.getAllConversations()) - } + val conversations by rememberAsyncMutableState(defaultValue = emptyList()) { + loggerWrapper.getAllConversations().toMutableList() } ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/TasksRoot.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap - import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle @@ -28,6 +28,7 @@ import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.common.data.download.DownloadMetadata import me.rhunk.snapenhance.common.data.download.MediaDownloadSource import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.FFMpegProcessor @@ -133,34 +134,46 @@ class TasksRoot : Routes.Route() { } } - override val topBarActions: @Composable() (RowScope.() -> Unit) = { + override val topBarActions: @Composable (RowScope.() -> Unit) = { var showConfirmDialog by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() - if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { - taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile -> - IconButton(onClick = { - runCatching { - context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { - setDataAndType(documentFile.uri, documentFile.type) - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK - }) - taskSelection.clear() - }.onFailure { - context.log.error("Failed to open file ${taskSelection.first().second}", it) + if (taskSelection.size == 1) { + val selectionExists by rememberAsyncMutableState(defaultValue = false) { + taskSelection.firstOrNull()?.second?.exists() == true + } + if (selectionExists) { + taskSelection.firstOrNull()?.second?.let { documentFile -> + IconButton(onClick = { + runCatching { + context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(documentFile.uri, documentFile.type) + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + }) + taskSelection.clear() + }.onFailure { + context.log.error("Failed to open file ${taskSelection.first().second}", it) + } + }) { + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open") } - }) { - Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open") } } } - if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { - IconButton(onClick = { - mergeSelection(taskSelection.toList().also { - taskSelection.clear() - }.map { it.first to it.second!! }) - }) { - Icon(Icons.Filled.Merge, contentDescription = "Merge") + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + + if (canMergeSelection) { + IconButton(onClick = { + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }) { + Icon(Icons.Filled.Merge, contentDescription = "Merge") + } } } @@ -187,9 +200,12 @@ class TasksRoot : Routes.Route() { if (taskSelection.isNotEmpty()) { Text(translation["remove_selected_tasks_title"]) Row ( - modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable { - alsoDeleteFiles = !alsoDeleteFiles - }, + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth() + .clickable { + alsoDeleteFiles = !alsoDeleteFiles + }, horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -207,19 +223,22 @@ class TasksRoot : Routes.Route() { Button( onClick = { showConfirmDialog = false - if (taskSelection.isNotEmpty()) { taskSelection.forEach { (task, documentFile) -> - context.taskManager.removeTask(task) - recentTasks.remove(task) - if (alsoDeleteFiles) { - documentFile?.delete() + coroutineScope.launch(Dispatchers.IO) { + context.taskManager.removeTask(task) + if (alsoDeleteFiles) { + documentFile?.delete() + } } + recentTasks.remove(task) } activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } taskSelection.clear() } else { - context.taskManager.clearAllTasks() + coroutineScope.launch(Dispatchers.IO) { + context.taskManager.clearAllTasks() + } recentTasks.clear() activeTasks.forEach { runCatching { @@ -255,16 +274,17 @@ class TasksRoot : Routes.Route() { var taskProgressLabel by remember { mutableStateOf<String?>(null) } var taskProgress by remember { mutableIntStateOf(-1) } val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } - var documentFile by remember { mutableStateOf<DocumentFile?>(null) } - var isDocumentFileReadable by remember { mutableStateOf(true) } - LaunchedEffect(taskStatus.key) { - launch(Dispatchers.IO) { - documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch) - isDocumentFileReadable = documentFile?.canRead() ?: false + var documentFileMimeType by remember { mutableStateOf("") } + var isDocumentFileReadable by remember { mutableStateOf(true) } + val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) { + DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply { + documentFileMimeType = type ?: "" + isDocumentFileReadable = canRead() } } + val listener = remember { PendingTaskListener( onStateChange = { taskStatus = it @@ -285,19 +305,21 @@ class TasksRoot : Routes.Route() { } } - OutlinedCard(modifier = modifier.clickable { - if (isSelected) { - taskSelection.removeIf { it.first == task } - return@clickable + OutlinedCard(modifier = modifier + .clickable { + if (isSelected) { + taskSelection.removeIf { it.first == task } + return@clickable + } + taskSelection.add(task to documentFile) } - taskSelection.add(task to documentFile) - }.let { - if (isSelected) { - it - .border(2.dp, MaterialTheme.colorScheme.primary) - .clip(MaterialTheme.shapes.medium) - } else it - }) { + .let { + if (isSelected) { + it + .border(2.dp, MaterialTheme.colorScheme.primary) + .clip(MaterialTheme.shapes.medium) + } else it + }) { Row( modifier = Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically @@ -305,13 +327,12 @@ class TasksRoot : Routes.Route() { Column( modifier = Modifier.padding(end = 15.dp) ) { - documentFile?.let { file -> - val mimeType = file.type ?: "" + documentFile?.let { when { !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") - mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") - mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") - mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") + documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") + documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") + documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") } } ?: run { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRoot.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -45,7 +44,6 @@ import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.* -@OptIn(ExperimentalMaterial3Api::class) class FeaturesRoot : Routes.Route() { private val alertDialogs by lazy { AlertDialogs(context.translation) } @@ -313,7 +311,7 @@ class FeaturesRoot : Routes.Route() { FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722), ) - Card( + ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) @@ -327,24 +325,14 @@ class FeaturesRoot : Routes.Route() { .padding(all = 4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { - property.key.params.icon?.let { iconName -> - //TODO: find a better way to load icons - val icon: ImageVector? = remember(iconName) { - runCatching { - val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt") - val method = cl.declaredMethods.first() - method.invoke(null, Icons.Filled) as ImageVector - }.getOrNull() - } - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 10.dp) - ) - } + property.key.params.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 10.dp) + ) } Column( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeLogs.kt @@ -31,6 +31,7 @@ import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import me.rhunk.snapenhance.LogReader import me.rhunk.snapenhance.common.logger.LogChannel @@ -64,8 +65,12 @@ class HomeLogs : Routes.Route() { modifier = Modifier.align(Alignment.CenterVertically) ) { DropdownMenuItem(onClick = { - context.log.clearLogs() - navigate() + context.coroutineScope.launch { + context.log.clearLogs() + } + routes.navController.navigate(routeInfo.id) { + popUpTo(routeInfo.id) { inclusive = true } + } showDropDown = false }, text = { Text(translation["clear_logs_button"]) @@ -148,63 +153,73 @@ class HomeLogs : Routes.Route() { } } items(lineCount) { index -> - val logLine = remember(index) { logReader?.getLogLine(index) } ?: return@items + val logLine by remember(index) { + mutableStateOf(runBlocking(Dispatchers.IO) { + logReader?.getLogLine(index) + }) + } var expand by remember { mutableStateOf(false) } - Box(modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { - coroutineScope.launch { - clipboardManager.setText(AnnotatedString(logLine.message)) - } - }, - onTap = { - expand = !expand - } - ) - }) { - - Row( - modifier = Modifier - .padding(4.dp) - .fillMaxWidth() - .defaultMinSize(minHeight = 30.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (!expand) { - Icon( - imageVector = when (logLine.logLevel) { - LogLevel.DEBUG -> Icons.Outlined.BugReport - LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report - LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info - LogLevel.WARN -> Icons.Outlined.Warning + logLine?.let { line -> + Box(modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + coroutineScope.launch { + clipboardManager.setText( + AnnotatedString( + line.message + ) + ) + } }, - contentDescription = null, + onTap = { + expand = !expand + } ) + }) { + Row( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = 30.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!expand) { + Icon( + imageVector = when (line.logLevel) { + LogLevel.DEBUG -> Icons.Outlined.BugReport + LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report + LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info + LogLevel.WARN -> Icons.Outlined.Warning + else -> Icons.Outlined.Info + }, + contentDescription = null, + ) - Text( - text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag, - modifier = Modifier.padding(start = 4.dp), - fontWeight = FontWeight.Light, - fontSize = 10.sp, - ) + Text( + text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag, + modifier = Modifier.padding(start = 4.dp), + fontWeight = FontWeight.Light, + fontSize = 10.sp, + ) + + Text( + text = line.dateTime, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + fontSize = 10.sp + ) + } Text( - text = logLine.dateTime, - modifier = Modifier.padding(start = 4.dp, end = 4.dp), - fontSize = 10.sp + text = line.message.trimIndent(), + fontSize = 10.sp, + maxLines = if (expand) Int.MAX_VALUE else 6, + overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis, + softWrap = !expand, ) } - - Text( - text = logLine.message.trimIndent(), - fontSize = 10.sp, - maxLines = if (expand) Int.MAX_VALUE else 6, - overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis, - softWrap = !expand, - ) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeRoot.kt @@ -2,42 +2,47 @@ package me.rhunk.snapenhance.ui.manager.pages.home import android.content.Intent import android.net.Uri -import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.rhunk.snapenhance.R import me.rhunk.snapenhance.common.BuildConfig +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.action.EnumAction +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.storage.getQuickTiles +import me.rhunk.snapenhance.storage.setQuickTiles import me.rhunk.snapenhance.ui.manager.Routes -import me.rhunk.snapenhance.ui.manager.data.InstallationSummary import me.rhunk.snapenhance.ui.manager.data.Updater import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper -import me.rhunk.snapenhance.ui.util.OnLifecycleEvent class HomeRoot : Routes.Route() { companion object { @@ -46,61 +51,33 @@ class HomeRoot : Routes.Route() { private lateinit var activityLauncherHelper: ActivityLauncherHelper - override val init: () -> Unit = { - activityLauncherHelper = ActivityLauncherHelper(context.activity!!) + private fun launchActionIntent(action: EnumAction) { + val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME) + intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key) + context.androidContext.startActivity(intent) } - @Composable - private fun SummaryCards(installationSummary: InstallationSummary) { - val summaryInfo = remember { - mapOf( - "Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"), - "Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"), - "Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"), - "Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"), - "Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"), - "Device" to installationSummary.platformInfo.device, - "Android Version" to installationSummary.platformInfo.androidVersion, - "System ABI" to installationSummary.platformInfo.systemAbi - ) - } - - Card( - modifier = Modifier - .padding(all = cardMargin) - .fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 10.dp), - ) { - summaryInfo.forEach { (title, value) -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(all = 5.dp), - ) { - Text( - text = title, - fontSize = 12.sp, - fontWeight = FontWeight.Light, - ) - Text( - fontSize = 14.sp, - text = value, - lineHeight = 20.sp - ) - } + private val cards by lazy { + mapOf( + ("Friend Tracker" to Icons.Default.PersonSearch) to { + routes.friendTracker.navigateReset() + }, + ("Logger History" to Icons.Default.History) to { + routes.loggerHistory.navigateReset() + }, + ).toMutableMap().apply { + EnumAction.entries.forEach { action -> + this[context.translation["actions.${action.key}.name"] to action.icon] = { + launchActionIntent(action) } } } } + override val init: () -> Unit = { + activityLauncherHelper = ActivityLauncherHelper(context.activity !!) + } + override val topBarActions: @Composable (RowScope.() -> Unit) = { IconButton(onClick = { routes.homeLogs.navigate() @@ -114,6 +91,36 @@ class HomeRoot : Routes.Route() { } } + @Composable + fun LinkIcon( + modifier: Modifier = Modifier, + size: Dp = 32.dp, + imageVector: ImageVector, + dataArray: IntArray + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(size) + .then(modifier) + .clickable { + context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse( + dataArray + .map { it.toChar() } + .joinToString("") + .reversed() + ) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }) + } + ) + } + + + @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) override val content: @Composable (NavBackStackEntry) -> Unit = { val avenirNextFontFamily = remember { FontFamily( @@ -121,26 +128,17 @@ class HomeRoot : Routes.Route() { ) } - var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) } - Column( modifier = Modifier + .fillMaxSize() .verticalScroll(ScrollState(0)) ) { - - Image( - painter = painterResource(id = R.drawable.launcher_icon_monochrome), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - contentScale = ContentScale.FillHeight, - modifier = Modifier - .fillMaxWidth() - .scale(1.8f) - .height(90.dp) - ) - Text( - text = remember { intArrayOf(101,99,110,97,104,110,69,112,97,110,83).map { it.toChar() }.joinToString("").reversed() }, + text = remember { + intArrayOf( + 101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83 + ).map { it.toChar() }.joinToString("").reversed() + }, fontSize = 30.sp, fontFamily = avenirNextFontFamily, modifier = Modifier.align(Alignment.CenterHorizontally), @@ -153,52 +151,50 @@ class HomeRoot : Routes.Route() { modifier = Modifier.align(Alignment.CenterHorizontally), ) - Text( - text = "An xposed module made to enhance your Snapchat experience", - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally), - modifier = Modifier + horizontalArrangement = Arrangement.spacedBy( + 15.dp, Alignment.CenterHorizontally + ), modifier = Modifier .fillMaxWidth() .padding(all = 10.dp) ) { - Icon( + LinkIcon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp).clickable { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse( - intArrayOf(101,99,110,97,104,110,69,112,97,110,83,47,107,110,117,104,114,47,109,111,99,46,98,117,104,116,105,103,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() - ) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } + dataArray = intArrayOf( + 101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83, 47, 107, 110, + 117, 104, 114, 47, 109, 111, 99, 46, 98, 117, 104, 116, 105, + 103, 47, 58, 115, 112, 116, 116, 104 + ) ) - Icon( + + LinkIcon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp).clickable { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse( - intArrayOf(101,99,110,97,104,110,101,112,97,110,115,47,101,109,46,116,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed() - ) - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - ) - } + dataArray = intArrayOf( + 101, 99, 110, 97, 104, 110, 101, 112, 97, 110, 115, 47, 101, + 109, 46, 116, 47, 47, 58, 115, 112, 116, 116, 104 + ) + ) + + LinkIcon( + size = 36.dp, + modifier = Modifier.offset(y = (-2).dp), + imageVector = Icons.AutoMirrored.Default.Help, + dataArray = intArrayOf( + 105, 107, 105, 119, 47, 101, 99, 110, 97, 104, 110, 69, 112, 97, + 110, 83, 47, 107, 110, 117, 104, 114, 47, 109, 111, 99, 46, 98, + 117, 104, 116, 105, 103, 47, 47, 58, 115, 112, 116, 116, 104 + ) ) } + val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) { + context.database.getQuickTiles() + } + + val latestUpdate by rememberAsyncMutableState(defaultValue = null) { + if (!BuildConfig.DEBUG) Updater.checkForLatestRelease() else null + } + if (latestUpdate != null) { Spacer(modifier = Modifier.height(20.dp)) OutlinedCard( @@ -209,7 +205,7 @@ class HomeRoot : Routes.Route() { containerColor = MaterialTheme.colorScheme.surfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) - ){ + ) { Row( modifier = Modifier .fillMaxWidth() @@ -223,17 +219,16 @@ class HomeRoot : Routes.Route() { fontWeight = FontWeight.Bold, ) Text( - fontSize = 12.sp, - text = translation.format("update_content", "version" to (latestUpdate?.versionName ?: "unknown")), - lineHeight = 20.sp + fontSize = 12.sp, text = translation.format( + "update_content", + "version" to (latestUpdate?.versionName ?: "unknown") + ), lineHeight = 20.sp ) } Button(onClick = { - context.activity?.startActivity( - Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(latestUpdate?.releaseUrl) - } - ) + context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(latestUpdate?.releaseUrl) + }) }, modifier = Modifier.height(40.dp)) { Text(text = translation["update_button"]) } @@ -241,38 +236,93 @@ class HomeRoot : Routes.Route() { } } - val coroutineScope = rememberCoroutineScope() - var installationSummary by remember { mutableStateOf(null as InstallationSummary?) } + var showQuickActionsMenu by remember { mutableStateOf(false) } - fun updateInstallationSummary(scope: CoroutineScope) { - scope.launch(Dispatchers.IO) { - runCatching { - installationSummary = context.installationSummary - }.onFailure { - context.longToast("SnapEnhance failed to load installation summary: ${it.message}") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 30.dp, top = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Quick Actions", fontSize = 20.sp, modifier = Modifier.weight(1f)) + Box { + IconButton( + onClick = { showQuickActionsMenu = !showQuickActionsMenu }, + ) { + Icon(Icons.Default.MoreVert, contentDescription = null) } - runCatching { - if (!BuildConfig.DEBUG) { - latestUpdate = Updater.checkForLatestRelease() + DropdownMenu( + expanded = showQuickActionsMenu, + onDismissRequest = { showQuickActionsMenu = false } + ) { + cards.forEach { (card, _) -> + fun toggle(state: Boolean? = null) { + if (state?.let { !it } ?: selectedTiles.contains(card.first)) { + selectedTiles.remove(card.first) + } else { + selectedTiles.add(0, card.first) + } + context.coroutineScope.launch { + context.database.setQuickTiles(selectedTiles) + } + } + + DropdownMenuItem(onClick = { toggle() }, text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(all = 5.dp) + ) { + Checkbox( + checked = selectedTiles.contains(card.first), + onCheckedChange = { + toggle(it) + } + ) + Text(text = card.first) + } + }) } - }.onFailure { - context.longToast("SnapEnhance failed to check for updates: ${it.message}") } } - } - OnLifecycleEvent { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - updateInstallationSummary(coroutineScope) - } } - LaunchedEffect(Unit) { - updateInstallationSummary(coroutineScope) + FlowRow( + modifier = Modifier + .padding(all = cardMargin) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + remember(selectedTiles.size, context.translation.loadedLocale) { selectedTiles.mapNotNull { + cards.entries.find { entry -> entry.key.first == it } + } }.forEach { (card, action) -> + ElevatedCard( + modifier = Modifier + .size(105.dp) + .weight(1f) + .clickable { action() } + .padding(all = 6.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(all = 5.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + Icon( + imageVector = card.second, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(50.dp) + ) + Text( + lineHeight = 16.sp, text = card.first, fontSize = 11.sp, + textAlign = TextAlign.Center + ) + } + } + } } - - Spacer(modifier = Modifier.height(20.dp)) - installationSummary?.let { SummaryCards(installationSummary = it) } } } -}- \ No newline at end of file +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -18,10 +18,12 @@ import androidx.compose.ui.window.Dialog import androidx.core.net.toUri import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.bridge.types.BridgeFileType +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.setup.Requirements import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper @@ -165,13 +167,11 @@ class HomeSettings : Routes.Route() { Column( verticalArrangement = Arrangement.spacedBy(4.dp), ) { - var storedMessagesCount by remember { mutableIntStateOf(0) } - var storedStoriesCount by remember { mutableIntStateOf(0) } - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - storedMessagesCount = context.messageLogger.getStoredMessageCount() - storedStoriesCount = context.messageLogger.getStoredStoriesCount() - } + var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { + context.messageLogger.getStoredMessageCount() + } + var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { + context.messageLogger.getStoredStoriesCount() } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -273,7 +273,9 @@ class HomeSettings : Routes.Route() { } Button(onClick = { runCatching { - selectedFileType.resolve(context.androidContext).delete() + context.coroutineScope.launch { + selectedFileType.resolve(context.androidContext).delete() + } }.onFailure { context.log.error("Failed to clear file", it) context.longToast("Failed to clear file! ${it.localizedMessage}") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/scripting/ScriptingRoot.kt @@ -6,17 +6,21 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.LibraryBooks -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -26,8 +30,14 @@ import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import me.rhunk.snapenhance.common.scripting.ui.EnumScriptInterface import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface +import me.rhunk.snapenhance.common.ui.AsyncUpdateDispatcher +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.storage.getScripts +import me.rhunk.snapenhance.storage.isScriptEnabled +import me.rhunk.snapenhance.storage.setScriptEnabled import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper +import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.chooseFolder import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh @@ -35,19 +45,213 @@ import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState class ScriptingRoot : Routes.Route() { private lateinit var activityLauncherHelper: ActivityLauncherHelper + private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false) override val init: () -> Unit = { activityLauncherHelper = ActivityLauncherHelper(context.activity!!) } @Composable + private fun ImportRemoteScript( + dismiss: () -> Unit + ) { + Dialog(onDismissRequest = dismiss) { + var url by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + var isLoading by remember { + mutableStateOf(false) + } + ElevatedCard( + modifier = Modifier + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Import Script from URL", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(8.dp), + ) + Text( + text = "Warning: Imported scripts can be harmful to your device. Only import scripts from trusted sources.", + fontSize = 14.sp, + fontWeight = FontWeight.Light, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(8.dp), + textAlign = TextAlign.Center, + ) + TextField( + value = url, + onValueChange = { + url = it + }, + label = { + Text(text = "Enter URL here:") + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onGloballyPositioned { + focusRequester.requestFocus() + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + enabled = url.isNotBlank(), + onClick = { + isLoading = true + context.coroutineScope.launch { + runCatching { + val moduleInfo = context.scriptManager.importFromUrl(url) + context.shortToast("Script ${moduleInfo.name} imported!") + reloadDispatcher.dispatch() + withContext(Dispatchers.Main) { + dismiss() + } + return@launch + }.onFailure { + context.log.error("Failed to import script", it) + context.shortToast("Failed to import script. ${it.message}. Check logs for more details") + } + isLoading = false + } + }, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(30.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(text = "Import") + } + } + } + } + } + } + + + @Composable + private fun ModuleActions( + script: ModuleInfo, + dismiss: () -> Unit + ) { + Dialog( + onDismissRequest = dismiss, + ) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + ) { + val actions = remember { + mapOf<Pair<String, ImageVector>, suspend () -> Unit>( + ("Edit Module" to Icons.Default.Edit) to { + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name)!! + context.androidContext.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = context.scriptManager.getScriptsFolder()!! + .findFile(modulePath)!!.uri + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + } + ) + dismiss() + }.onFailure { + context.log.error("Failed to open module file", it) + context.shortToast("Failed to open module file. Check logs for more details") + } + }, + ("Clear Module Data" to Icons.Default.Save) to { + runCatching { + context.scriptManager.getModuleDataFolder(script.name) + .deleteRecursively() + context.shortToast("Module data cleared!") + dismiss() + }.onFailure { + context.log.error("Failed to clear module data", it) + context.shortToast("Failed to clear module data. Check logs for more details") + } + }, + ("Delete Module" to Icons.Default.DeleteOutline) to { + context.scriptManager.apply { + runCatching { + val modulePath = getModulePath(script.name)!! + unloadScript(modulePath) + getScriptsFolder()?.findFile(modulePath)?.delete() + reloadDispatcher.dispatch() + context.shortToast("Deleted script ${script.name}!") + dismiss() + }.onFailure { + context.log.error("Failed to delete module", it) + context.shortToast("Failed to delete module. Check logs for more details") + } + } + } + ) + } + + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + item { + Text( + text = "Actions", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + items(actions.size) { index -> + val action = actions.entries.elementAt(index) + ListItem( + modifier = Modifier + .clickable { + context.coroutineScope.launch { + action.value() + dismiss() + } + } + .fillMaxWidth(), + leadingContent = { + Icon( + imageVector = action.key.second, + contentDescription = action.key.first + ) + }, + headlineContent = { + Text(text = action.key.first) + }, + ) + } + } + } + } + } + + @Composable fun ModuleItem(script: ModuleInfo) { - var enabled by remember { - mutableStateOf(context.modDatabase.isScriptEnabled(script.name)) + var enabled by rememberAsyncMutableState(defaultValue = false) { + context.database.isScriptEnabled(script.name) } var openSettings by remember { mutableStateOf(false) } + var openActions by remember { + mutableStateOf(false) + } Card( modifier = Modifier @@ -59,43 +263,64 @@ class ScriptingRoot : Routes.Route() { modifier = Modifier .fillMaxWidth() .clickable { + if (!enabled) return@clickable openSettings = !openSettings } .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { + if (enabled) { + Icon( + imageVector = if (openSettings) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier + .padding(end = 8.dp) + .size(32.dp), + ) + } + Column( modifier = Modifier .weight(1f) .padding(end = 8.dp) ) { - Text(text = script.displayName ?: script.name, fontSize = 20.sp,) - Text(text = script.description ?: "No description", fontSize = 14.sp,) + Text(text = script.displayName ?: script.name, fontSize = 20.sp) + Text(text = script.description ?: "No description", fontSize = 14.sp) } - IconButton(onClick = { openSettings = !openSettings }) { - Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",) + IconButton(onClick = { + openActions = !openActions + }) { + Icon(imageVector = Icons.Default.Build, contentDescription = "Actions") } Switch( checked = enabled, onCheckedChange = { isChecked -> - context.modDatabase.setScriptEnabled(script.name, isChecked) - enabled = isChecked - runCatching { - val modulePath = context.scriptManager.getModulePath(script.name)!! - context.scriptManager.unloadScript(modulePath) - if (isChecked) { - context.scriptManager.loadScript(modulePath) - context.scriptManager.runtime.getModuleByName(script.name) - ?.callFunction("module.onSnapEnhanceLoad") - context.shortToast("Loaded script ${script.name}") - } else { - context.shortToast("Unloaded script ${script.name}") - } - }.onFailure { throwable -> - enabled = !isChecked - ("Failed to ${if (isChecked) "enable" else "disable"} script").let { - context.log.error(it, throwable) - context.shortToast(it) + openSettings = false + context.coroutineScope.launch(Dispatchers.IO) { + runCatching { + val modulePath = context.scriptManager.getModulePath(script.name)!! + context.scriptManager.unloadScript(modulePath) + if (isChecked) { + context.scriptManager.loadScript(modulePath) + context.scriptManager.runtime.getModuleByName(script.name) + ?.callFunction("module.onSnapEnhanceLoad") + context.shortToast("Loaded script ${script.name}") + } else { + context.shortToast("Unloaded script ${script.name}") + } + + context.database.setScriptEnabled(script.name, isChecked) + withContext(Dispatchers.Main) { + enabled = isChecked + } + }.onFailure { throwable -> + withContext(Dispatchers.Main) { + enabled = !isChecked + } + ("Failed to ${if (isChecked) "enable" else "disable"} script. Check logs for more details").also { + context.log.error(it, throwable) + context.shortToast(it) + } } } } @@ -106,18 +331,31 @@ class ScriptingRoot : Routes.Route() { ScriptSettings(script) } } + + if (openActions) { + ModuleActions(script) { openActions = false } + } } override val floatingActionButton: @Composable () -> Unit = { + var showImportDialog by remember { + mutableStateOf(false) + } + if (showImportDialog) { + ImportRemoteScript { + showImportDialog = false + } + } + Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.End, ) { ExtendedFloatingActionButton( onClick = { - + showImportDialog = true }, - icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, + icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") }, text = { Text(text = "Import from URL") }, @@ -133,7 +371,12 @@ class ScriptingRoot : Routes.Route() { ) } }, - icon= { Icon(imageVector = Icons.Default.FolderOpen, contentDescription = "Folder") }, + icon = { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = "Folder" + ) + }, text = { Text(text = "Open Scripts Folder") }, @@ -144,8 +387,9 @@ class ScriptingRoot : Routes.Route() { @Composable fun ScriptSettings(script: ModuleInfo) { - val settingsInterface = remember { - val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null + val settingsInterface = remember { + val module = + context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null (module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS) } @@ -155,43 +399,44 @@ class ScriptingRoot : Routes.Route() { style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(8.dp) ) - } else { + } else { ScriptInterface(interfaceBuilder = settingsInterface) } } override val content: @Composable (NavBackStackEntry) -> Unit = { - var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) } - var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) } + val scriptingFolder by rememberAsyncMutableState( + defaultValue = null, + updateDispatcher = reloadDispatcher + ) { + context.scriptManager.getScriptsFolder() + } + val scriptModules by rememberAsyncMutableState( + defaultValue = emptyList(), + updateDispatcher = reloadDispatcher + ) { + context.scriptManager.sync() + context.database.getScripts() + } + val coroutineScope = rememberCoroutineScope() var refreshing by remember { mutableStateOf(false) } - fun syncScripts() { - runCatching { - scriptingFolder = context.scriptManager.getScriptsFolder() - context.scriptManager.sync() - scriptModules = context.modDatabase.getScripts() - }.onFailure { - context.log.error("Failed to sync scripts", it) - } - } - LaunchedEffect(Unit) { refreshing = true withContext(Dispatchers.IO) { - syncScripts() + reloadDispatcher.dispatch() refreshing = false } } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = { refreshing = true - syncScripts() - coroutineScope.launch { - delay(300) + coroutineScope.launch(Dispatchers.IO) { + reloadDispatcher.dispatch() refreshing = false } }) @@ -206,7 +451,7 @@ class ScriptingRoot : Routes.Route() { horizontalAlignment = Alignment.CenterHorizontally ) { item { - if (scriptingFolder == null) { + if (scriptingFolder == null && !refreshing) { Text( text = "No scripts folder selected", style = MaterialTheme.typography.bodySmall, @@ -218,7 +463,7 @@ class ScriptingRoot : Routes.Route() { context.config.root.scripting.moduleFolder.set(it) context.config.writeConfig() coroutineScope.launch { - syncScripts() + reloadDispatcher.dispatch() } } }) { @@ -295,7 +540,10 @@ class ScriptingRoot : Routes.Route() { flags = Intent.FLAG_ACTIVITY_NEW_TASK }) }) { - Icon(imageVector = Icons.AutoMirrored.Default.LibraryBooks, contentDescription = "Documentation") + Icon( + imageVector = Icons.AutoMirrored.Default.LibraryBooks, + contentDescription = "Documentation" + ) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/AddFriendDialog.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -27,45 +26,74 @@ import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.common.ReceiversConfig import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo -import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.common.util.snap.SnapWidgetBroadcastReceiverHelper +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage class AddFriendDialog( private val context: RemoteSideContext, - private val socialRoot: SocialRoot, + private val actionHandler: Actions, ) { + class Actions( + val onFriendState: (friend: MessagingFriendInfo, state: Boolean) -> Unit, + val onGroupState: (group: MessagingGroupInfo, state: Boolean) -> Unit, + val getFriendState: (friend: MessagingFriendInfo) -> Boolean, + val getGroupState: (group: MessagingGroupInfo) -> Boolean, + ) + private val stateCache = mutableMapOf<String, Boolean>() private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")} @Composable - private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) { - var currentState by remember { mutableStateOf(getCurrentState()) } + private fun ListCardEntry( + id: String, + bitmoji: String? = null, + name: String, + getCurrentState: () -> Boolean, + onState: (Boolean) -> Unit = {}, + ) { + var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) { + getCurrentState().also { stateCache[id] = it } + } + val coroutineScope = rememberCoroutineScope() Row( modifier = Modifier .fillMaxWidth() .clickable { currentState = !currentState - onState(currentState) + stateCache[id] = currentState + coroutineScope.launch(Dispatchers.IO) { + onState(currentState) + } } .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { + BitmojiImage( + context = this@AddFriendDialog.context, + url = bitmoji, + modifier = Modifier.padding(end = 2.dp), + size = 32, + ) + Text( text = name, fontSize = 15.sp, modifier = Modifier .weight(1f) - .onGloballyPositioned { - currentState = getCurrentState() - } ) Checkbox( checked = currentState, onCheckedChange = { currentState = it - onState(currentState) + stateCache[id] = currentState + coroutineScope.launch(Dispatchers.IO) { + onState(currentState) + } } ) } @@ -122,7 +150,7 @@ class AddFriendDialog( var hasFetchError by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - context.modDatabase.receiveMessagingDataCallback = { friends, groups -> + context.database.receiveMessagingDataCallback = { friends, groups -> cachedFriends = friends cachedGroups = groups timeoutJob?.cancel() @@ -138,7 +166,7 @@ class AddFriendDialog( } timeoutJob = coroutineScope.launch { withContext(Dispatchers.IO) { - delay(10000) + delay(20000) hasFetchError = true } } @@ -216,15 +244,11 @@ class AddFriendDialog( items(filteredGroups.size) { val group = filteredGroups[it] ListCardEntry( + id = group.conversationId, name = group.name, - getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null } + getCurrentState = { actionHandler.getGroupState(group) } ) { state -> - if (state) { - context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId) - } else { - context.modDatabase.deleteGroup(group.conversationId) - } - socialRoot.updateScopeLists() + actionHandler.onGroupState(group, state) } } @@ -237,19 +261,18 @@ class AddFriendDialog( ) } - items(filteredFriends.size) { - val friend = filteredFriends[it] + items(filteredFriends.size) { index -> + val friend = filteredFriends[index] ListCardEntry( + id = friend.userId, + bitmoji = friend.takeIf { it.bitmojiId != null }?.let { + BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + }, name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername, - getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null } + getCurrentState = { actionHandler.getFriendState(friend) } ) { state -> - if (state) { - context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId) - } else { - context.modDatabase.deleteFriend(friend.userId) - } - socialRoot.updateScopeLists() + actionHandler.onFriendState(friend, state) } } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/LoggedStories.kt @@ -29,10 +29,10 @@ import me.rhunk.snapenhance.common.data.StoryData import me.rhunk.snapenhance.common.data.download.* import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.storage.getFriendInfo import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.Dialog import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper -import okhttp3.OkHttpClient import java.io.File import java.text.DateFormat import java.util.Date @@ -45,7 +45,7 @@ class LoggedStories : Routes.Route() { val userId = navBackStackEntry.arguments?.getString("id") ?: return@content val stories = remember { mutableStateListOf<StoryData>() } - val friendInfo = remember { context.modDatabase.getFriendInfo(userId) } + val friendInfo = remember { context.database.getFriendInfo(userId) } var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } var selectedStory by remember { mutableStateOf<StoryData?>(null) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/ManageScope.kt @@ -17,13 +17,19 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import me.rhunk.snapenhance.common.data.FriendStreaks +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.AlertDialogs -import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.Dialog +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -32,10 +38,10 @@ class ManageScope: Routes.Route() { private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) { when (scope) { - SocialScope.FRIEND -> context.modDatabase.deleteFriend(id) - SocialScope.GROUP -> context.modDatabase.deleteGroup(id) + SocialScope.FRIEND -> context.database.deleteFriend(id) + SocialScope.GROUP -> context.database.deleteGroup(id) } - context.modDatabase.executeAsync { + context.database.executeAsync { coroutineScope.launch { routes.navController.popBackStack() } @@ -79,47 +85,97 @@ class ManageScope: Routes.Route() { val id = navBackStackEntry.arguments?.getString("id")!! Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() ) { + var hasScope by remember { + mutableStateOf(null as Boolean?) + } when (scope) { - SocialScope.FRIEND -> Friend(id) - SocialScope.GROUP -> Group(id) + SocialScope.FRIEND -> { + var streaks by remember { mutableStateOf(null as FriendStreaks?) } + val friend by rememberAsyncMutableState(null) { + context.database.getFriendInfo(id)?.also { + streaks = context.database.getFriendStreaks(id) + }.also { + hasScope = it != null + } + } + friend?.let { + Friend(id, it, streaks) + } + } + SocialScope.GROUP -> { + val group by rememberAsyncMutableState(null) { + context.database.getGroupInfo(id).also { + hasScope = it != null + } + } + group?.let { + Group(it) + } + } + } + if (hasScope == true) { + RulesCard(id) + } + if (hasScope == false) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = translation["not_found"], + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } } + } + } - Spacer(modifier = Modifier.height(16.dp)) - val rules = context.modDatabase.getRules(id) + @Composable + private fun RulesCard( + id: String + ) { + Spacer(modifier = Modifier.height(16.dp)) - SectionTitle(translation["rules_title"]) + val rules = rememberAsyncMutableStateList(listOf()) { + context.database.getRules(id) + } - ContentCard { - MessagingRuleType.entries.forEach { ruleType -> - var ruleEnabled by remember { - mutableStateOf(rules.any { it.key == ruleType.key }) - } + SectionTitle(translation["rules_title"]) - val ruleState = context.config.root.rules.getRuleState(ruleType) + ContentCard { + MessagingRuleType.entries.forEach { ruleType -> + var ruleEnabled by remember(rules.size) { + mutableStateOf(rules.any { it.key == ruleType.key }) + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(all = 4.dp) - ) { - Text( - text = if (ruleType.listMode && ruleState != null) { - context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] - } else context.translation["rules.properties.${ruleType.key}.name"], - modifier = Modifier - .weight(1f) - .padding(start = 5.dp, end = 5.dp) - ) - Switch(checked = ruleEnabled, - enabled = if (ruleType.listMode) ruleState != null else true, - onCheckedChange = { - context.modDatabase.setRule(id, ruleType.key, it) - ruleEnabled = it - } - ) - } + val ruleState = context.config.root.rules.getRuleState(ruleType) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(all = 4.dp) + ) { + Text( + text = if (ruleType.listMode && ruleState != null) { + context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] + } else context.translation["rules.properties.${ruleType.key}.name"], + modifier = Modifier + .weight(1f) + .padding(start = 5.dp, end = 5.dp) + ) + Switch(checked = ruleEnabled, + enabled = if (ruleType.listMode) ruleState != null else true, + onCheckedChange = { + context.database.setRule(id, ruleType.key, it) + ruleEnabled = it + } + ) } } } @@ -185,17 +241,11 @@ class ManageScope: Routes.Route() { @OptIn(ExperimentalEncodingApi::class) @Composable - private fun Friend(id: String) { - //fetch the friend from the database - val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run { - Text(text = translation["not_found"]) - return - } - - val streaks = remember { - context.modDatabase.getFriendStreaks(id) - } - + private fun Friend( + id: String, + friend: MessagingFriendInfo, + streaks: FriendStreaks? + ) { Column( modifier = Modifier .padding(10.dp) @@ -275,7 +325,7 @@ class ManageScope: Routes.Route() { modifier = Modifier.padding(end = 10.dp) ) Switch(checked = shouldNotify, onCheckedChange = { - context.modDatabase.setFriendStreaksNotify(id, it) + context.database.setFriendStreaksNotify(id, it) shouldNotify = it }) } @@ -286,7 +336,9 @@ class ManageScope: Routes.Route() { if (context.config.root.experimental.e2eEncryption.globalState == true) { SectionTitle(translation["e2ee_title"]) - var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))} + var hasSecretKey by rememberAsyncMutableState(defaultValue = false) { + context.e2eeImplementation.friendKeyExists(friend.userId) + } var importDialog by remember { mutableStateOf(false) } if (importDialog) { @@ -302,8 +354,11 @@ class ManageScope: Routes.Route() { return@runCatching } - context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) - context.longToast("Successfully imported key") + context.coroutineScope.launch { + context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) + context.longToast("Successfully imported key") + } + hasSecretKey = true }.onFailure { context.longToast("Failed to import key: ${it.message}") @@ -320,20 +375,22 @@ class ManageScope: Routes.Route() { ) { if (hasSecretKey) { OutlinedButton(onClick = { - val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton) - //TODO: fingerprint auth - context.activity!!.startActivity(Intent.createChooser(Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, secretKey) - type = "text/plain" - }, "").apply { - putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( - Intent().apply { - putExtra(Intent.EXTRA_TEXT, secretKey) - putExtra(Intent.EXTRA_SUBJECT, secretKey) - }) - ) - }) + context.coroutineScope.launch { + val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch) + //TODO: fingerprint auth + context.activity!!.startActivity(Intent.createChooser(Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, secretKey) + type = "text/plain" + }, "").apply { + putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( + Intent().apply { + putExtra(Intent.EXTRA_TEXT, secretKey) + putExtra(Intent.EXTRA_SUBJECT, secretKey) + }) + ) + }) + } }) { Text( text = "Export Base64", @@ -355,13 +412,7 @@ class ManageScope: Routes.Route() { } @Composable - private fun Group(id: String) { - //fetch the group from the database - val group = remember { context.modDatabase.getGroupInfo(id) } ?: run { - Text(text = translation["not_found"]) - return - } - + private fun Group(group: MessagingGroupInfo) { Column( modifier = Modifier .padding(10.dp) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/MessagingPreview.kt @@ -478,12 +478,14 @@ class MessagingPreview: Routes.Route() { isBridgeConnected = context.hasMessagingBridge() if (isBridgeConnected) { - onMessagingBridgeReady(scope, id) - } else { - SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also { - context.androidContext.sendBroadcast(it) + withContext(Dispatchers.IO) { + onMessagingBridgeReady(scope, id) } + } else { coroutineScope.launch(Dispatchers.IO) { + SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also { + context.androidContext.sendBroadcast(it) + } withTimeout(10000) { while (!context.hasMessagingBridge()) { delay(100) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/social/SocialRoot.kt @@ -22,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import me.rhunk.snapenhance.R import me.rhunk.snapenhance.common.data.MessagingFriendInfo import me.rhunk.snapenhance.common.data.MessagingGroupInfo import me.rhunk.snapenhance.common.data.SocialScope +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.util.coil.BitmojiImage import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset @@ -38,15 +38,32 @@ class SocialRoot : Routes.Route() { private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList()) private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList()) - fun updateScopeLists() { - context.coroutineScope.launch(Dispatchers.IO) { - friendList = context.modDatabase.getFriends(descOrder = true) - groupList = context.modDatabase.getGroups() + private fun updateScopeLists() { + context.coroutineScope.launch { + friendList = context.database.getFriends(descOrder = true) + groupList = context.database.getGroups() } } private val addFriendDialog by lazy { - AddFriendDialog(context, this) + AddFriendDialog(context, AddFriendDialog.Actions( + onFriendState = { friend, state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId) + } else { + context.database.deleteFriend(friend.userId) + } + }, + onGroupState = { group, state -> + if (state) { + context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId) + } else { + context.database.deleteGroup(group.conversationId) + } + }, + getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null }, + getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null } + )) } @Composable @@ -82,7 +99,7 @@ class SocialRoot : Routes.Route() { SocialScope.FRIEND -> friendList[index].userId } - Card( + ElevatedCard( modifier = Modifier .padding(10.dp) .fillMaxWidth() @@ -119,12 +136,8 @@ class SocialRoot : Routes.Route() { SocialScope.FRIEND -> { val friend = friendList[index] - var streaks by remember { mutableStateOf(friend.streaks) } - - LaunchedEffect(friend.userId) { - withContext(Dispatchers.IO) { - streaks = context.modDatabase.getFriendStreaks(friend.userId) - } + val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) { + context.database.getFriendStreaks(friend.userId) } BitmojiImage( @@ -204,6 +217,11 @@ class SocialRoot : Routes.Route() { addFriendDialog.Content { showAddFriendDialog = false } + DisposableEffect(Unit) { + onDispose { + updateScopeLists() + } + } } LaunchedEffect(Unit) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/EditRule.kt @@ -0,0 +1,463 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import me.rhunk.snapenhance.common.data.TrackerEventType +import me.rhunk.snapenhance.common.data.TrackerRuleAction +import me.rhunk.snapenhance.common.data.TrackerRuleActionParams +import me.rhunk.snapenhance.common.data.TrackerRuleEvent +import me.rhunk.snapenhance.common.data.TrackerScopeType +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.manager.pages.social.AddFriendDialog + +@Composable +fun ActionCheckbox( + text: String, + checked: MutableState<Boolean>, + onChanged: (Boolean) -> Unit = {} +) { + Row( + modifier = Modifier.clickable { + checked.value = !checked.value + onChanged(checked.value) + }, + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + modifier = Modifier.size(30.dp), + checked = checked.value, + onCheckedChange = { + checked.value = it + onChanged(it) + } + ) + Text(text, fontSize = 12.sp) + } +} + + +@Composable +fun ConditionCheckboxes( + params: TrackerRuleActionParams +) { + ActionCheckbox(text = "Only when I'm inside conversation", checked = remember { mutableStateOf(params.onlyInsideConversation) }, onChanged = { params.onlyInsideConversation = it }) + ActionCheckbox(text = "Only when I'm outside conversation", checked = remember { mutableStateOf(params.onlyOutsideConversation) }, onChanged = { params.onlyOutsideConversation = it }) + ActionCheckbox(text = "Only when Snapchat is active", checked = remember { mutableStateOf(params.onlyWhenAppActive) }, onChanged = { params.onlyWhenAppActive = it }) + ActionCheckbox(text = "Only when Snapchat is inactive", checked = remember { mutableStateOf(params.onlyWhenAppInactive) }, onChanged = { params.onlyWhenAppInactive = it }) + ActionCheckbox(text = "No notification when Snapchat is active", checked = remember { mutableStateOf(params.noPushNotificationWhenAppActive) }, onChanged = { params.noPushNotificationWhenAppActive = it }) +} + +class EditRule : Routes.Route() { + private val fab = mutableStateOf<@Composable (() -> Unit)?>(null) + + // persistent add event state + private var currentEventType by mutableStateOf(TrackerEventType.CONVERSATION_ENTER.key) + private var addEventActions by mutableStateOf(emptySet<TrackerRuleAction>()) + private val addEventActionParams by mutableStateOf(TrackerRuleActionParams()) + + override val floatingActionButton: @Composable () -> Unit = { + fab.value?.invoke() + } + + @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { navBackStackEntry -> + val currentRuleId = navBackStackEntry.arguments?.getString("rule_id")?.toIntOrNull() + + val events = rememberAsyncMutableStateList(defaultValue = emptyList()) { + currentRuleId?.let { ruleId -> + context.database.getTrackerEvents(ruleId) + } ?: emptyList() + } + var currentScopeType by remember { mutableStateOf(TrackerScopeType.BLACKLIST) } + val scopes = rememberAsyncMutableStateList(defaultValue = emptyList()) { + currentRuleId?.let { ruleId -> + context.database.getRuleTrackerScopes(ruleId).also { + currentScopeType = if (it.isEmpty()) { + TrackerScopeType.WHITELIST + } else { + it.values.first() + } + }.map { it.key } + } ?: emptyList() + } + val ruleName = rememberAsyncMutableState(defaultValue = "", keys = arrayOf(currentRuleId)) { + currentRuleId?.let { ruleId -> + context.database.getTrackerRule(ruleId)?.name ?: "Custom Rule" + } ?: "Custom Rule" + } + + LaunchedEffect(Unit) { + fab.value = { + var deleteConfirmation by remember { mutableStateOf(false) } + + if (deleteConfirmation) { + AlertDialog( + onDismissRequest = { deleteConfirmation = false }, + title = { Text("Delete Rule") }, + text = { Text("Are you sure you want to delete this rule?") }, + confirmButton = { + Button( + onClick = { + if (currentRuleId != null) { + context.database.deleteTrackerRule(currentRuleId) + } + routes.navController.popBackStack() + } + ) { + Text("Delete") + } + }, + dismissButton = { + Button( + onClick = { deleteConfirmation = false } + ) { + Text("Cancel") + } + } + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.End + ) { + ExtendedFloatingActionButton( + onClick = { + val ruleId = currentRuleId ?: context.database.newTrackerRule() + events.forEach { event -> + context.database.addOrUpdateTrackerRuleEvent( + event.id.takeIf { it > -1 }, + ruleId, + event.eventType, + event.params, + event.actions + ) + } + context.database.setTrackerRuleName(ruleId, ruleName.value.trim()) + context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes) + routes.navController.popBackStack() + }, + text = { Text("Save Rule") }, + icon = { Icon(Icons.Default.Save, contentDescription = "Save Rule") } + ) + + if (currentRuleId != null) { + ExtendedFloatingActionButton( + containerColor = MaterialTheme.colorScheme.error, + onClick = { deleteConfirmation = true }, + text = { Text("Delete Rule") }, + icon = { Icon(Icons.Default.DeleteOutline, contentDescription = "Delete Rule") } + ) + } + } + } + } + + DisposableEffect(Unit) { + onDispose { fab.value = null } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + TextField( + value = ruleName.value, + onValueChange = { + ruleName.value = it + }, + singleLine = true, + placeholder = { + Text( + "Rule Name", + fontSize = 18.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + }, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + textStyle = TextStyle(fontSize = 20.sp, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) + ) + } + + + item { + Column( + modifier = Modifier.fillMaxWidth(), + ){ + Text("Scope", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) + + var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) } + + val friendDialogActions = remember { + AddFriendDialog.Actions( + onFriendState = { friend, state -> + if (state) { + scopes.add(friend.userId) + } else { + scopes.remove(friend.userId) + } + }, + onGroupState = { group, state -> + if (state) { + scopes.add(group.conversationId) + } else { + scopes.remove(group.conversationId) + } + }, + getFriendState = { friend -> + friend.userId in scopes + }, + getGroupState = { group -> + group.conversationId in scopes + } + ) + } + + Box(modifier = Modifier.clickable { scopes.clear() }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isEmpty(), onClick = null) + Text("All Friends/Groups") + } + } + + Box(modifier = Modifier.clickable { + currentScopeType = TrackerScopeType.BLACKLIST + addFriendDialog = AddFriendDialog( + context, + friendDialogActions + ) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.BLACKLIST, onClick = null) + Text("Blacklist" + if (currentScopeType == TrackerScopeType.BLACKLIST && scopes.isNotEmpty()) " (" + scopes.size.toString() + ")" else "") + } + } + + Box(modifier = Modifier.clickable { + currentScopeType = TrackerScopeType.WHITELIST + addFriendDialog = AddFriendDialog( + context, + friendDialogActions + ) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = scopes.isNotEmpty() && currentScopeType == TrackerScopeType.WHITELIST, onClick = null) + Text("Whitelist" + if (currentScopeType == TrackerScopeType.WHITELIST && scopes.isNotEmpty()) " (" + scopes.size.toString() + ")" else "") + } + } + + addFriendDialog?.Content { + addFriendDialog = null + } + } + + var addEventDialog by remember { mutableStateOf(false) } + val showDropdown = remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Events", fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(16.dp)) + IconButton(onClick = { addEventDialog = true }, modifier = Modifier.padding(8.dp)) { + Icon(Icons.Default.Add, contentDescription = "Add Event", modifier = Modifier.size(32.dp)) + } + } + + if (addEventDialog) { + AlertDialog( + onDismissRequest = { addEventDialog = false }, + title = { Text("Add Event", fontSize = 20.sp, fontWeight = FontWeight.Bold) }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Type", fontSize = 14.sp, fontWeight = FontWeight.Bold) + ExposedDropdownMenuBox(expanded = showDropdown.value, onExpandedChange = { showDropdown.value = it }) { + ElevatedButton( + onClick = { showDropdown.value = true }, + modifier = Modifier.menuAnchor() + ) { + Text(currentEventType, overflow = TextOverflow.Ellipsis, maxLines = 1) + } + DropdownMenu(expanded = showDropdown.value, onDismissRequest = { showDropdown.value = false }) { + TrackerEventType.entries.forEach { eventType -> + DropdownMenuItem(onClick = { + currentEventType = eventType.key + showDropdown.value = false + }, text = { + Text(eventType.key) + }) + } + } + } + } + + Text("Triggers", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + ) { + TrackerRuleAction.entries.forEach { action -> + ActionCheckbox(action.name, checked = remember { mutableStateOf(addEventActions.contains(action)) }) { + if (it) { + addEventActions += action + } else { + addEventActions -= action + } + } + } + } + + Text("Conditions", fontSize = 14.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(2.dp)) + ConditionCheckboxes(addEventActionParams) + } + }, + confirmButton = { + Button( + onClick = { + events.add(0, TrackerRuleEvent(-1, true, currentEventType, addEventActionParams.copy(), addEventActions.toList())) + addEventDialog = false + } + ) { + Text("Add") + } + } + ) + } + } + + item { + if (events.isEmpty()) { + Text("No events", fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier + .padding(10.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } + } + + items(events) { event -> + var expanded by remember { mutableStateOf(false) } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .padding(4.dp), + onClick = { expanded = !expanded } + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Column { + Text(event.eventType, lineHeight = 20.sp, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Text(text = event.actions.joinToString(", ") { it.name }, fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 14.sp) + } + } + OutlinedIconButton( + onClick = { + if (event.id > -1) { + context.database.deleteTrackerRuleEvent(event.id) + } + events.remove(event) + } + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + if (expanded) { + Column( + modifier = Modifier.padding(10.dp) + ) { + ConditionCheckboxes(event.params) + } + } + } + } + } + + item { + Spacer(modifier = Modifier.height(140.dp)) + } + } + } +} + diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/tracker/FriendTrackerManagerRoot.kt @@ -0,0 +1,470 @@ +package me.rhunk.snapenhance.ui.manager.pages.tracker + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties +import androidx.navigation.NavBackStackEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.bridge.wrapper.TrackerLog +import me.rhunk.snapenhance.common.data.MessagingFriendInfo +import me.rhunk.snapenhance.common.data.TrackerEventType +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList +import me.rhunk.snapenhance.common.ui.rememberAsyncUpdateDispatcher +import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.storage.* +import me.rhunk.snapenhance.ui.manager.Routes +import me.rhunk.snapenhance.ui.util.coil.BitmojiImage +import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset +import java.text.DateFormat + + +@OptIn(ExperimentalFoundationApi::class) +class FriendTrackerManagerRoot : Routes.Route() { + enum class FilterType { + CONVERSATION, USERNAME, EVENT + } + + private val titles = listOf("Logs", "Rules") + private var currentPage by mutableIntStateOf(0) + + override val floatingActionButton: @Composable () -> Unit = { + if (currentPage == 1) { + ExtendedFloatingActionButton( + icon = { Icon(Icons.Default.Add, contentDescription = "Add Rule") }, + expanded = true, + text = { Text("Add Rule") }, + onClick = { routes.editRule.navigate() } + ) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun LogsTab() { + val coroutineScope = rememberCoroutineScope() + + val logs = remember { mutableStateListOf<TrackerLog>() } + var lastTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } + var filterType by remember { mutableStateOf(FilterType.USERNAME) } + + var filter by remember { mutableStateOf("") } + var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } + + suspend fun loadNewLogs() { + withContext(Dispatchers.IO) { + logs.addAll(context.messageLogger.getLogs(lastTimestamp, filter = { + when (filterType) { + FilterType.USERNAME -> it.username.contains(filter, ignoreCase = true) + FilterType.CONVERSATION -> it.conversationTitle?.contains(filter, ignoreCase = true) == true || (it.username == filter && !it.isGroup) + FilterType.EVENT -> it.eventType.contains(filter, ignoreCase = true) + } + }).apply { + lastTimestamp = minOfOrNull { it.timestamp } ?: lastTimestamp + }) + } + } + + suspend fun resetAndLoadLogs() { + logs.clear() + lastTimestamp = Long.MAX_VALUE + loadNewLogs() + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + var showAutoComplete by remember { mutableStateOf(false) } + var dropDownExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = showAutoComplete, + onExpandedChange = { showAutoComplete = it }, + ) { + TextField( + value = filter, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + .padding(8.dp), + onValueChange = { + filter = it + coroutineScope.launch { + searchTimeoutJob?.cancel() + searchTimeoutJob = coroutineScope.launch { + delay(200) + showAutoComplete = true + resetAndLoadLogs() + } + } + }, + placeholder = { Text("Search") }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + maxLines = 1, + leadingIcon = { + ExposedDropdownMenuBox( + expanded = dropDownExpanded, + onExpandedChange = { dropDownExpanded = it }, + ) { + ElevatedCard( + modifier = Modifier + .menuAnchor() + .padding(2.dp) + ) { + Text(filterType.name, modifier = Modifier.padding(8.dp)) + } + DropdownMenu(expanded = dropDownExpanded, onDismissRequest = { + dropDownExpanded = false + }) { + FilterType.entries.forEach { type -> + DropdownMenuItem(onClick = { + filter = "" + filterType = type + dropDownExpanded = false + coroutineScope.launch { + resetAndLoadLogs() + } + }, text = { + Text(type.name) + }) + } + } + } + }, + trailingIcon = { + if (filter != "") { + IconButton(onClick = { + filter = "" + coroutineScope.launch { + resetAndLoadLogs() + } + }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + + DropdownMenu( + expanded = showAutoComplete, + onDismissRequest = { + showAutoComplete = false + }, + properties = PopupProperties(focusable = false), + ) { + val suggestedEntries = remember(filter) { + mutableStateListOf<String>() + } + + LaunchedEffect(filter) { + launch(Dispatchers.IO) { + suggestedEntries.addAll(when (filterType) { + FilterType.USERNAME -> context.messageLogger.findUsername(filter) + FilterType.CONVERSATION -> context.messageLogger.findConversation(filter) + context.messageLogger.findUsername(filter) + FilterType.EVENT -> TrackerEventType.entries.filter { it.name.contains(filter, ignoreCase = true) }.map { it.key } + }.take(5)) + } + } + + suggestedEntries.forEach { entry -> + DropdownMenuItem(onClick = { + filter = entry + coroutineScope.launch { + resetAndLoadLogs() + } + showAutoComplete = false + }, text = { + Text(entry) + }) + } + } + }, + ) + } + } + + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + if (logs.isEmpty()) { + Text("No logs found", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light) + } + } + items(logs, key = { it.userId + it.id }) { log -> + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(5.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + var databaseFriend by remember { mutableStateOf<MessagingFriendInfo?>(null) } + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + databaseFriend = context.database.getFriendInfo(log.userId) + } + } + BitmojiImage( + modifier = Modifier.padding(10.dp), + size = 70, + context = context, + url = databaseFriend?.takeIf { it.bitmojiId != null }?.let { + BitmojiSelfie.getBitmojiSelfie(it.selfieId, it.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) + }, + ) + + Column( + modifier = Modifier + .weight(1f), + ) { + Text(databaseFriend?.displayName?.let { + "$it (${log.username})" + } ?: log.username, lineHeight = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${log.eventType} in ${log.conversationTitle}", fontSize = 15.sp, fontWeight = FontWeight.Light, lineHeight = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + DateFormat.getDateTimeInstance().format(log.timestamp), + fontSize = 10.sp, + fontWeight = FontWeight.Light, + lineHeight = 15.sp, + ) + } + + OutlinedIconButton( + onClick = { + context.messageLogger.deleteTrackerLog(log.id) + logs.remove(log) + } + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = "Delete") + } + } + } + } + item { + Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(lastTimestamp) { + loadNewLogs() + } + } + } + } + + } + + @Composable + private fun ConfigRulesTab() { + val updateRules = rememberAsyncUpdateDispatcher() + val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) { + context.database.getTrackerRulesDesc() + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.weight(1f) + ) { + item { + if (rules.isEmpty()) { + Text("No rules found", modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), textAlign = TextAlign.Center, fontWeight = FontWeight.Light) + } + } + items(rules, key = { it.id }) { rule -> + val ruleName by rememberAsyncMutableState(defaultValue = rule.name) { + context.database.getTrackerRule(rule.id)?.name ?: "(empty)" + } + val eventCount by rememberAsyncMutableState(defaultValue = 0) { + context.database.getTrackerEvents(rule.id).size + } + val scopeCount by rememberAsyncMutableState(defaultValue = 0) { + context.database.getRuleTrackerScopes(rule.id).size + } + var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) { + context.database.getTrackerRule(rule.id)?.enabled ?: false + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable { + routes.editRule.navigate { + this["rule_id"] = rule.id.toString() + } + } + .padding(5.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text(ruleName, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text(buildString { + append(eventCount) + append(" events") + if (scopeCount > 0) { + append(", ") + append(scopeCount) + append(" scopes") + } + }, fontSize = 13.sp, fontWeight = FontWeight.Light) + } + + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) { + context.database.getRuleTrackerScopes(rule.id, limit = 10).mapNotNull { + context.database.getFriendInfo(it.key)?.let { friend -> + friend.selfieId to friend.bitmojiId + } + }.take(3) + } + + Row { + scopesBitmoji.forEachIndexed { index, friend -> + Box( + modifier = Modifier + .offset(x = (-index * 20).dp + (scopesBitmoji.size * 20).dp - 20.dp) + ) { + BitmojiImage( + size = 50, + modifier = Modifier + .border( + BorderStroke(1.dp, Color.White), + CircleShape + ) + .background(Color.White, CircleShape) + .clip(CircleShape), + context = context, + url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D), + ) + } + } + } + + Box(modifier = Modifier + .padding(start = 5.dp, end = 5.dp) + .height(50.dp) + .width(1.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = RoundedCornerShape(5.dp) + ) + ) + + Switch( + checked = enabled, + onCheckedChange = { + enabled = it + context.database.setTrackerRuleState(rule.id, it) + } + ) + } + } + } + } + } + } + } + + + @OptIn(ExperimentalFoundationApi::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState { titles.size } + currentPage = pagerState.currentPage + + Column { + TabRow(selectedTabIndex = pagerState.currentPage, indicator = { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.pagerTabIndicatorOffset( + pagerState = pagerState, + tabPositions = tabPositions + ) + ) + }) { + titles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + text = title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + + HorizontalPager( + modifier = Modifier.weight(1f), + state = pagerState + ) { page -> + when (page) { + 0 -> LogsTab() + 1 -> ConfigRulesTab() + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/coil/ComposeImageHelper.kt @@ -29,7 +29,7 @@ fun BitmojiImage(context: RemoteSideContext, modifier: Modifier = Modifier, size imageLoader = context.imageLoader ), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Inside, modifier = Modifier .requiredWidthIn(min = 0.dp, max = size.dp) .height(size.dp) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -33,6 +33,8 @@ "home_logs": "Logs", "logger_history": "Logger History", "logged_stories": "Logged Stories", + "friend_tracker": "Friend Tracker", + "edit_rule": "Edit Rule", "social": "Social", "manage_scope": "Manage Scope", "messaging_preview": "Preview", @@ -878,20 +880,6 @@ } } }, - "session_events": { - "name": "Session Events", - "description": "Records session events", - "properties": { - "capture_duplex_events": { - "name": "Capture Duplex Events", - "description": "Capture presence and messaging events when a session is active" - }, - "allow_running_in_background": { - "name": "Allow Running in Background", - "description": "Allows session to run in the background" - } - } - }, "spoof": { "name": "Spoof", "description": "Spoof various information about you", @@ -1039,6 +1027,20 @@ "description": "Disables the anonymization of logs" } } + }, + "friend_tracker": { + "name": "Friend Tracker", + "description": "Records friend's activity on Snapchat", + "properties": { + "record_messaging_events": { + "name": "Record Messaging Events", + "description": "Records messaging events such as sending a opening a snap, reading a message, etc." + }, + "allow_running_in_background": { + "name": "Allow Running in Background", + "description": "Allows the tracker to run in the background. Note: This will significantly drain your battery" + } + } } }, "options": { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt @@ -1,16 +1,24 @@ package me.rhunk.snapenhance.common.action +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PersonOutline +import androidx.compose.ui.graphics.vector.ImageVector enum class EnumAction( val key: String, + val icon: ImageVector, val exitOnFinish: Boolean = false, ) { - EXPORT_CHAT_MESSAGES("export_chat_messages"), - EXPORT_MEMORIES("export_memories"), - BULK_MESSAGING_ACTION("bulk_messaging_action"), - MANAGE_FRIEND_LIST("manage_friend_list"), - CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true); + EXPORT_CHAT_MESSAGES("export_chat_messages", Icons.AutoMirrored.Default.Chat), + EXPORT_MEMORIES("export_memories", Icons.Default.Image), + BULK_MESSAGING_ACTION("bulk_messaging_action", Icons.Default.DeleteOutline), + CLEAN_CACHE("clean_snapchat_cache", Icons.Default.CleaningServices, exitOnFinish = true), + MANAGE_FRIEND_LIST("manage_friend_list", Icons.Default.PersonOutline); companion object { const val ACTION_PARAMETER = "se_action" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/LoggerWrapper.kt @@ -2,25 +2,34 @@ package me.rhunk.snapenhance.common.bridge.wrapper import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject import kotlinx.coroutines.* import me.rhunk.snapenhance.bridge.logger.LoggerInterface import me.rhunk.snapenhance.common.data.StoryData +import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull import me.rhunk.snapenhance.common.util.ktx.getIntOrNull import me.rhunk.snapenhance.common.util.ktx.getLongOrNull import me.rhunk.snapenhance.common.util.ktx.getStringOrNull +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import java.io.File import java.util.UUID +class LoggedMessageEdit( + val timestamp: Long, + val messageText: String +) class LoggedMessage( val messageId: Long, val timestamp: Long, - val messageData: ByteArray + val messageData: ByteArray, ) class TrackerLog( + val id: Int, val timestamp: Long, val conversationId: String, val conversationTitle: String?, @@ -37,6 +46,7 @@ class LoggerWrapper( private var _database: SQLiteDatabase? = null @OptIn(ExperimentalCoroutinesApi::class) private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + private val gson by lazy { GsonBuilder().create() } private val database get() = synchronized(this) { _database?.takeIf { it.isOpen } ?: run { @@ -50,6 +60,14 @@ class LoggerWrapper( "message_id BIGINT", "message_data BLOB" ), + "chat_edits" to listOf( + "id INTEGER PRIMARY KEY", + "edit_number INTEGER", + "added_timestamp BIGINT", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_text BLOB" + ), "stories" to listOf( "id INTEGER PRIMARY KEY", "added_timestamp BIGINT", @@ -111,18 +129,66 @@ class LoggerWrapper( } override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) { - val cursor = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) - val state = cursor.moveToFirst() - cursor.close() - if (state) return + val hasMessage = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())).use { + it.moveToFirst() + it.count > 0 + } + + if (!hasMessage) { + runBlocking { + withContext(coroutineScope.coroutineContext) { + database.insert("messages", null, ContentValues().apply { + put("added_timestamp", System.currentTimeMillis()) + put("conversation_id", conversationId) + put("message_id", messageId) + put("message_data", serializedMessage) + }) + } + } + } + + // handle message edits runBlocking { withContext(coroutineScope.coroutineContext) { - database.insert("messages", null, ContentValues().apply { - put("added_timestamp", System.currentTimeMillis()) - put("conversation_id", conversationId) - put("message_id", messageId) - put("message_data", serializedMessage) - }) + runCatching { + val messageObject = gson.fromJson( + serializedMessage.toString(Charsets.UTF_8), + JsonObject::class.java + ) + if (messageObject.getAsJsonObject("mMessageContent") + ?.getAsJsonPrimitive("mContentType")?.asString != "CHAT" + ) return@withContext + + val metadata = messageObject.getAsJsonObject("mMetadata") + if (metadata.get("mIsEdited")?.asBoolean != true) return@withContext + + val messageTextContent = + messageObject.getAsJsonObject("mMessageContent")?.getAsJsonArray("mContent") + ?.map { it.asByte }?.toByteArray()?.let { + ProtoReader(it).getString(2, 1) + } ?: return@withContext + + database.rawQuery( + "SELECT MAX(edit_number), message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ?", + arrayOf(conversationId, messageId.toString()) + ).use { + it.moveToFirst() + val editNumber = it.getInt(0) + val lastEditedMessage = it.getString(1) + + if (lastEditedMessage == messageTextContent) return@withContext + + database.insert("chat_edits", null, ContentValues().apply { + put("edit_number", editNumber + 1) + put("added_timestamp", System.currentTimeMillis()) + put("conversation_id", conversationId) + put("message_id", messageId) + put("message_text", messageTextContent) + }) + } + }.onFailure { + AbstractLogger.directDebug("Failed to handle message edit: ${it.message}") + } } } } @@ -132,9 +198,11 @@ class LoggerWrapper( maxAge?.let { val maxTime = System.currentTimeMillis() - it database.execSQL("DELETE FROM messages WHERE added_timestamp < ?", arrayOf(maxTime.toString())) + database.execSQL("DELETE FROM chat_edits WHERE added_timestamp < ?", arrayOf(maxTime.toString())) database.execSQL("DELETE FROM stories WHERE added_timestamp < ?", arrayOf(maxTime.toString())) } ?: run { database.execSQL("DELETE FROM messages") + database.execSQL("DELETE FROM chat_edits") database.execSQL("DELETE FROM stories") } } @@ -157,6 +225,7 @@ class LoggerWrapper( override fun deleteMessage(conversationId: String, messageId: Long) { coroutineScope.launch { database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) + database.execSQL("DELETE FROM chat_edits WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) } } @@ -207,6 +276,12 @@ class LoggerWrapper( } } + fun deleteTrackerLog(id: Int) { + coroutineScope.launch { + database.execSQL("DELETE FROM tracker_events WHERE id = ?", arrayOf(id.toString())) + } + } + fun getLogs( lastTimestamp: Long, filter: ((TrackerLog) -> Boolean)? = null @@ -215,6 +290,7 @@ class LoggerWrapper( val logs = mutableListOf<TrackerLog>() while (it.moveToNext() && logs.size < 50) { val log = TrackerLog( + id = it.getIntOrNull("id") ?: continue, timestamp = it.getLongOrNull("timestamp") ?: continue, conversationId = it.getStringOrNull("conversation_id") ?: continue, conversationTitle = it.getStringOrNull("conversation_title"), @@ -278,6 +354,22 @@ class LoggerWrapper( } } + fun getMessageEdits(conversationId: String, messageId: Long): List<LoggedMessageEdit> { + val edits = mutableListOf<LoggedMessageEdit>() + database.rawQuery( + "SELECT added_timestamp, message_text FROM chat_edits WHERE conversation_id = ? AND message_id = ?", + arrayOf(conversationId, messageId.toString()) + ).use { + while (it.moveToNext()) { + edits.add(LoggedMessageEdit( + timestamp = it.getLongOrNull("added_timestamp") ?: continue, + messageText = it.getStringOrNull("message_text") ?: continue + )) + } + } + return edits + } + fun fetchMessages( conversationId: String, fromTimestamp: Long, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -1,5 +1,6 @@ package me.rhunk.snapenhance.common.config +import androidx.compose.ui.graphics.vector.ImageVector import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import kotlin.reflect.KProperty @@ -35,7 +36,7 @@ class ConfigParams( private var _flags: Int? = null, private var _notices: Int? = null, - var icon: String? = null, + var icon: ImageVector? = null, var disabledKey: String? = null, var customTranslationPath: String? = null, var customOptionTranslationPath: String? = null, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -1,14 +1,12 @@ package me.rhunk.snapenhance.common.config.impl +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Memory import me.rhunk.snapenhance.common.config.ConfigContainer import me.rhunk.snapenhance.common.config.FeatureNotice class Experimental : ConfigContainer() { - class SessionEventsConfig : ConfigContainer(hasGlobalState = true) { - val captureDuplexEvents = boolean("capture_duplex_events", true) - val allowRunningInBackground = boolean("allow_running_in_background", true) - } - class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) { val showFirstCreatedUsername = boolean("show_first_created_username") val bypassCameraRollLimit = boolean("bypass_camera_roll_limit") @@ -34,9 +32,8 @@ class Experimental : ConfigContainer() { val lockOnResume = boolean("lock_on_resume", defaultValue = true) } - val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } - val sessionEvents = container("session_events", SessionEventsConfig()) { requireRestart(); nativeHooks() } - val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } + val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() } + val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() } val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/FriendTrackerConfig.kt @@ -0,0 +1,8 @@ +package me.rhunk.snapenhance.common.config.impl + +import me.rhunk.snapenhance.common.config.ConfigContainer + +class FriendTrackerConfig: ConfigContainer(hasGlobalState = true) { + val recordMessagingEvents = boolean("record_messaging_events", false) + val allowRunningInBackground = boolean("allow_running_in_background", false) +}+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/RootConfig.kt @@ -1,17 +1,22 @@ package me.rhunk.snapenhance.common.config.impl +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.* import me.rhunk.snapenhance.common.config.ConfigContainer import me.rhunk.snapenhance.common.config.FeatureNotice class RootConfig : ConfigContainer() { - val downloader = container("downloader", DownloaderConfig()) { icon = "Download"} - val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"} - val messaging = container("messaging", MessagingTweaks()) { icon = "Send" } - val global = container("global", Global()) { icon = "MiscellaneousServices" } - val rules = container("rules", Rules()) { icon = "Rule" } - val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() } - val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" } - val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices( + val downloader = container("downloader", DownloaderConfig()) { icon = Icons.Default.Download } + val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = Icons.Default.RemoveRedEye } + val messaging = container("messaging", MessagingTweaks()) { icon = Icons.AutoMirrored.Default.Send } + val global = container("global", Global()) { icon = Icons.Default.MiscellaneousServices } + val rules = container("rules", Rules()) { icon = Icons.AutoMirrored.Default.Rule } + val camera = container("camera", Camera()) { icon = Icons.Default.Camera; requireRestart() } + val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = Icons.Default.Alarm } + val experimental = container("experimental", Experimental()) { icon = Icons.Default.Science; addNotices( FeatureNotice.UNSTABLE) } - val scripting = container("scripting", Scripting()) { icon = "DataObject" } + val scripting = container("scripting", Scripting()) { icon = Icons.Default.DataObject } + val friendTracker = container("friend_tracker", FriendTrackerConfig()) { icon = Icons.Default.PersonSearch; nativeHooks() } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/SessionEventsData.kt @@ -35,6 +35,7 @@ enum class SessionEventType( MESSAGE_DELETED("message_deleted"), MESSAGE_SAVED("message_saved"), MESSAGE_UNSAVED("message_unsaved"), + MESSAGE_EDITED("message_edited"), MESSAGE_REACTION_ADD("message_reaction_add"), MESSAGE_REACTION_REMOVE("message_reaction_remove"), SNAP_OPENED("snap_opened"), @@ -44,70 +45,6 @@ enum class SessionEventType( SNAP_SCREEN_RECORD("snap_screen_record"), } -object TrackerFlags { - const val TRACK = 1 - const val LOG = 2 - const val NOTIFY = 4 - const val APP_IS_ACTIVE = 8 - const val APP_IS_INACTIVE = 16 - const val IS_IN_CONVERSATION = 32 -} - -@Parcelize -class TrackerEventsResult( - private val rules: Map<TrackerRule, List<TrackerRuleEvent>> -): Parcelable { - fun hasFlags(vararg flags: Int): Boolean { - return rules.any { (_, ruleEvents) -> - ruleEvents.any { flags.all { flag -> it.flags and flag != 0 } } - } - } - - fun canTrackOn(conversationId: String?, userId: String?): Boolean { - return rules.any t@{ (rule, ruleEvents) -> - ruleEvents.any { event -> - if (event.flags and TrackerFlags.TRACK == 0) { - return@any false - } - - // global rule - if (rule.conversationId == null && rule.userId == null) { - return@any true - } - - // user rule - if (rule.conversationId == null && rule.userId == userId) { - return@any true - } - - // conversation rule - if (rule.conversationId == conversationId && rule.userId == null) { - return@any true - } - - // conversation and user rule - return@any rule.conversationId == conversationId && rule.userId == userId - } - } - } -} - - -@Parcelize -data class TrackerRule( - val id: Int, - val flags: Int, - val conversationId: String?, - val userId: String? -): Parcelable - -@Parcelize -data class TrackerRuleEvent( - val id: Int, - val flags: Int, - val eventType: String, -): Parcelable - enum class TrackerEventType( val key: String ) { @@ -126,6 +63,7 @@ enum class TrackerEventType( MESSAGE_DELETED("message_deleted"), MESSAGE_SAVED("message_saved"), MESSAGE_UNSAVED("message_unsaved"), + MESSAGE_EDITED("message_edited"), MESSAGE_REACTION_ADD("message_reaction_add"), MESSAGE_REACTION_REMOVE("message_reaction_remove"), SNAP_OPENED("snap_opened"), @@ -134,3 +72,104 @@ enum class TrackerEventType( SNAP_SCREENSHOT("snap_screenshot"), SNAP_SCREEN_RECORD("snap_screen_record"), } + + +@Parcelize +class TrackerEventsResult( + val rules: Map<ScopedTrackerRule, List<TrackerRuleEvent>>, +): Parcelable { + fun getActions(): Map<TrackerRuleAction, TrackerRuleActionParams> { + return rules.flatMap { + it.value + }.fold(mutableMapOf()) { acc, ruleEvent -> + ruleEvent.actions.forEach { action -> + acc[action] = acc[action]?.merge(ruleEvent.params) ?: ruleEvent.params + } + acc + } + } + + fun canTrackOn(conversationId: String?, userId: String?): Boolean { + return rules.any { (scopedRule, events) -> + if (!events.any { it.enabled }) return@any false + val scopes = scopedRule.scopes + + when (scopes[userId]) { + TrackerScopeType.WHITELIST -> return@any true + TrackerScopeType.BLACKLIST -> return@any false + else -> {} + } + + when (scopes[conversationId]) { + TrackerScopeType.WHITELIST -> return@any true + TrackerScopeType.BLACKLIST -> return@any false + else -> {} + } + + return@any scopes.isEmpty() || scopes.any { it.value == TrackerScopeType.BLACKLIST } + } + } +} + +enum class TrackerRuleAction( + val key: String +) { + LOG("log"), + IN_APP_NOTIFICATION("in_app_notification"), + PUSH_NOTIFICATION("push_notification"), + CUSTOM("custom"); + + companion object { + fun fromString(value: String): TrackerRuleAction? { + return entries.find { it.key == value } + } + } +} + +@Parcelize +data class TrackerRuleActionParams( + var onlyInsideConversation: Boolean = false, + var onlyOutsideConversation: Boolean = false, + var onlyWhenAppActive: Boolean = false, + var onlyWhenAppInactive: Boolean = false, + var noPushNotificationWhenAppActive: Boolean = false, +): Parcelable { + fun merge(other: TrackerRuleActionParams): TrackerRuleActionParams { + return TrackerRuleActionParams( + onlyInsideConversation = onlyInsideConversation || other.onlyInsideConversation, + onlyOutsideConversation = onlyOutsideConversation || other.onlyOutsideConversation, + onlyWhenAppActive = onlyWhenAppActive || other.onlyWhenAppActive, + onlyWhenAppInactive = onlyWhenAppInactive || other.onlyWhenAppInactive, + noPushNotificationWhenAppActive = noPushNotificationWhenAppActive || other.noPushNotificationWhenAppActive, + ) + } +} + +@Parcelize +data class TrackerRule( + val id: Int, + val enabled: Boolean, + val name: String, +): Parcelable + +@Parcelize +data class ScopedTrackerRule( + val rule: TrackerRule, + val scopes: Map<String, TrackerScopeType> +): Parcelable + +enum class TrackerScopeType( + val key: String +) { + WHITELIST("whitelist"), + BLACKLIST("blacklist"); +} + +@Parcelize +data class TrackerRuleEvent( + val id: Int, + val enabled: Boolean, + val eventType: String, + val params: TrackerRuleActionParams, + val actions: List<TrackerRuleAction> +): Parcelable diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/JSModule.kt @@ -4,8 +4,8 @@ import android.os.Handler import android.widget.Toast import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding import me.rhunk.snapenhance.common.scripting.bindings.BindingsContext -import me.rhunk.snapenhance.common.scripting.impl.Networking import me.rhunk.snapenhance.common.scripting.impl.JavaInterfaces +import me.rhunk.snapenhance.common.scripting.impl.Networking import me.rhunk.snapenhance.common.scripting.ktx.contextScope import me.rhunk.snapenhance.common.scripting.ktx.putFunction import me.rhunk.snapenhance.common.scripting.ktx.scriptable @@ -18,13 +18,14 @@ import org.mozilla.javascript.NativeJavaObject import org.mozilla.javascript.ScriptableObject import org.mozilla.javascript.Undefined import org.mozilla.javascript.Wrapper +import java.io.Reader import java.lang.reflect.Modifier import kotlin.reflect.KClass class JSModule( - val scriptRuntime: ScriptRuntime, + private val scriptRuntime: ScriptRuntime, val moduleInfo: ModuleInfo, - val content: String, + private val reader: Reader, ) { private val moduleBindings = mutableMapOf<String, AbstractBinding>() private lateinit var moduleObject: ScriptableObject @@ -53,6 +54,18 @@ class JSModule( }) }) + scriptRuntime.logger.apply { + moduleObject.putConst("console", moduleObject, scriptableObject { + putFunction("log") { info(argsToString(it)) } + putFunction("warn") { warn(argsToString(it)) } + putFunction("error") { error(argsToString(it)) } + putFunction("debug") { debug(argsToString(it)) } + putFunction("info") { info(argsToString(it)) } + putFunction("trace") { verbose(argsToString(it)) } + putFunction("verbose") { verbose(argsToString(it)) } + }) + } + registerBindings( JavaInterfaces(), InterfaceManager(), @@ -186,7 +199,7 @@ class JSModule( } contextScope(shouldOptimize = true) { - evaluateString(moduleObject, content, moduleInfo.name, 1, null) + evaluateReader(moduleObject, reader, moduleInfo.name, 1, null) } } @@ -233,7 +246,10 @@ class JSModule( private fun argsToString(args: Array<out Any?>?): String { return args?.joinToString(" ") { when (it) { - is Wrapper -> it.unwrap().toString() + is Wrapper -> it.unwrap().let { value -> + if (value is Throwable) value.message + "\n" + value.stackTraceToString() + else value.toString() + } else -> it.toString() } } ?: "null" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/scripting/ScriptRuntime.kt @@ -3,11 +3,11 @@ package me.rhunk.snapenhance.common.scripting import android.content.Context import android.os.ParcelFileDescriptor import me.rhunk.snapenhance.bridge.scripting.IScripting +import me.rhunk.snapenhance.common.BuildConfig import me.rhunk.snapenhance.common.logger.AbstractLogger import me.rhunk.snapenhance.common.scripting.type.ModuleInfo import org.mozilla.javascript.ScriptableObject import java.io.BufferedReader -import java.io.ByteArrayInputStream import java.io.InputStream open class ScriptRuntime( @@ -35,7 +35,7 @@ open class ScriptRuntime( return modules.values.find { it.moduleInfo.name == name } } - private fun readModuleInfo(reader: BufferedReader): ModuleInfo { + fun readModuleInfo(reader: BufferedReader): ModuleInfo { val header = reader.readLine() if (!header.startsWith("// ==SE_module==")) { throw Exception("Invalid module header") @@ -74,6 +74,10 @@ open class ScriptRuntime( return readModuleInfo(inputStream.bufferedReader()) } + fun removeModule(scriptPath: String) { + modules.remove(scriptPath) + } + fun unload(scriptPath: String) { val module = modules[scriptPath] ?: return logger.info("Unloading module $scriptPath") @@ -81,27 +85,30 @@ open class ScriptRuntime( modules.remove(scriptPath) } - fun load(scriptPath: String, pfd: ParcelFileDescriptor) { - load(scriptPath, ParcelFileDescriptor.AutoCloseInputStream(pfd).use { - it.readBytes().toString(Charsets.UTF_8) - }) + fun load(scriptPath: String, pfd: ParcelFileDescriptor): JSModule { + return ParcelFileDescriptor.AutoCloseInputStream(pfd).use { + load(scriptPath, it) + } } - fun load(scriptPath: String, content: String): JSModule? { + fun load(scriptPath: String, content: InputStream): JSModule { logger.info("Loading module $scriptPath") - return runCatching { - JSModule( - scriptRuntime = this, - moduleInfo = readModuleInfo(ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)).bufferedReader()), - content = content, - ).apply { - load { - buildModuleObject(this, this@apply) - } - modules[scriptPath] = this + val bufferedReader = content.bufferedReader() + val moduleInfo = readModuleInfo(bufferedReader) + + if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) { + throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})") + } + + return JSModule( + scriptRuntime = this, + moduleInfo = moduleInfo, + reader = bufferedReader, + ).apply { + load { + buildModuleObject(this, this@apply) } - }.onFailure { - logger.error("Failed to load module $scriptPath", it) - }.getOrNull() + modules[scriptPath] = this + } } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/ui/AsyncMutableState.kt @@ -0,0 +1,103 @@ +package me.rhunk.snapenhance.common.ui + +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArrayList + +class AsyncUpdateDispatcher( + val updateOnFirstComposition: Boolean = true +) { + private val callbacks = CopyOnWriteArrayList<suspend () -> Unit>() + + suspend fun dispatch() { + callbacks.forEach { it() } + } + + fun addCallback(callback: suspend () -> Unit) { + callbacks.add(callback) + } + + fun removeCallback(callback: suspend () -> Unit) { + callbacks.remove(callback) + } +} + +@Composable +fun rememberAsyncUpdateDispatcher(): AsyncUpdateDispatcher { + return remember { AsyncUpdateDispatcher() } +} + +@Composable +private fun <T> rememberCommonState( + initialState: () -> T, + setter: suspend T.() -> Unit, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), +): T { + return remember { initialState() }.apply { + var asyncSetCallback by remember { mutableStateOf(suspend {}) } + + LaunchedEffect(Unit) { + asyncSetCallback = { setter(this@apply) } + updateDispatcher?.addCallback(asyncSetCallback) + } + + DisposableEffect(Unit) { + onDispose { updateDispatcher?.removeCallback(asyncSetCallback) } + } + + if (updateDispatcher?.updateOnFirstComposition != false) { + LaunchedEffect(*keys) { + setter(this@apply) + } + } + } +} + +@Composable +fun <T> rememberAsyncMutableState( + defaultValue: T, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), + getter: () -> T, +): MutableState<T> { + return rememberCommonState( + initialState = { mutableStateOf(defaultValue) }, + setter = { + withContext(Dispatchers.Main) { + value = withContext(Dispatchers.IO) { + getter() + } + } + }, + updateDispatcher = updateDispatcher, + keys = keys, + ) +} + +@Composable +fun <T> rememberAsyncMutableStateList( + defaultValue: List<T>, + updateDispatcher: AsyncUpdateDispatcher? = null, + keys: Array<*> = emptyArray<Any>(), + getter: () -> List<T>, +): SnapshotStateList<T> { + return rememberCommonState( + initialState = { mutableStateListOf<T>().apply { + addAll(defaultValue) + }}, + setter = { + withContext(Dispatchers.Main) { + clear() + addAll(withContext(Dispatchers.IO) { + getter() + }) + } + }, + updateDispatcher = updateDispatcher, + keys = keys, + ) +} + diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/BitmojiSelfie.kt @@ -16,7 +16,7 @@ object BitmojiSelfie { return when (type) { BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1" BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle" - BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=1" + BitmojiSelfieType.NEW_THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle&ua=2" } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -17,15 +17,15 @@ import me.rhunk.snapenhance.common.Constants import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.common.config.ModConfig +import me.rhunk.snapenhance.core.action.ActionManager import me.rhunk.snapenhance.core.bridge.BridgeClient import me.rhunk.snapenhance.core.bridge.loadFromBridge import me.rhunk.snapenhance.core.database.DatabaseAccess import me.rhunk.snapenhance.core.event.EventBus import me.rhunk.snapenhance.core.event.EventDispatcher import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.logger.CoreLogger -import me.rhunk.snapenhance.core.action.ActionManager import me.rhunk.snapenhance.core.features.FeatureManager +import me.rhunk.snapenhance.core.logger.CoreLogger import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge import me.rhunk.snapenhance.core.messaging.MessageSender import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/BulkMessagingAction.kt @@ -36,11 +36,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.FriendLinkType +import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.messaging.MessagingConstraints import me.rhunk.snapenhance.common.messaging.MessagingTask import me.rhunk.snapenhance.common.messaging.MessagingTaskType import me.rhunk.snapenhance.common.ui.createComposeAlertDialog +import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie import me.rhunk.snapenhance.core.action.AbstractAction @@ -62,6 +64,8 @@ class BulkMessagingAction : AbstractAction() { ADDED_TIMESTAMP, SNAP_SCORE, STREAK_LENGTH, + MOST_MESSAGES_SENT, + MOST_RECENT_MESSAGE, } enum class Filter { @@ -172,6 +176,12 @@ class BulkMessagingAction : AbstractAction() { } } + private fun getDMLastMessage(userId: String?): ConversationMessage? { + return context.database.getConversationLinkFromUserId(userId ?: return null)?.clientConversationId?.let { + context.database.getMessagesFromConversationId(it, 1) + }?.firstOrNull() + } + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun BulkMessagingDialog() { @@ -198,6 +208,12 @@ class BulkMessagingAction : AbstractAction() { SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp } SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore } SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength } + SortBy.MOST_MESSAGES_SENT -> newFriends.sortByDescending { + getDMLastMessage(it.userId)?.serverMessageId ?: 0 + } + SortBy.MOST_RECENT_MESSAGE -> newFriends.sortByDescending { + getDMLastMessage(it.userId)?.creationTimestamp + } } if (sortReverseOrder) newFriends.reverse() withContext(Dispatchers.Main) { @@ -288,7 +304,7 @@ class BulkMessagingAction : AbstractAction() { modifier = Modifier .fillMaxWidth() .weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) + verticalArrangement = Arrangement.spacedBy(3.dp) ) { stickyHeader { Row( @@ -398,10 +414,14 @@ class BulkMessagingAction : AbstractAction() { horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically ){ - Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1) - Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1) + Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp) + Text(text = friendInfo.mutableUsername.toString(), fontSize = 10.sp, fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 1, lineHeight = 10.sp) } - val userInfo = remember(friendInfo) { + val lastMessage by rememberAsyncMutableState(defaultValue = null) { + getDMLastMessage(friendInfo.userId) + } + + val userInfo = remember(friendInfo, lastMessage) { buildString { append("Relationship: ") append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"]) @@ -414,9 +434,13 @@ class BulkMessagingAction : AbstractAction() { friendInfo.streakLength.takeIf { it > 0 }?.let { append("\nStreaks length: $it") } + lastMessage?.let { + append("\nSent messages: ${it.serverMessageId}") + append("\nLast message date: ${DateFormat.getDateTimeInstance().format(Date(it.creationTimestamp))}") + } } } - Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 16.sp, overflow = TextOverflow.Ellipsis) + Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 12.sp, overflow = TextOverflow.Ellipsis) } Checkbox( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ManageFriendList.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout -import me.rhunk.snapenhance.common.action.EnumAction import me.rhunk.snapenhance.common.data.FriendLinkType import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.core.action.AbstractAction diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -10,6 +10,7 @@ import me.rhunk.snapenhance.core.features.impl.downloader.ProfilePictureDownload import me.rhunk.snapenhance.core.features.impl.experiments.* import me.rhunk.snapenhance.core.features.impl.global.* import me.rhunk.snapenhance.core.features.impl.messaging.* +import me.rhunk.snapenhance.core.features.impl.spying.FriendTracker import me.rhunk.snapenhance.core.features.impl.spying.HalfSwipeNotifier import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.features.impl.spying.StealthMode @@ -112,7 +113,7 @@ class FeatureManager( OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), - SessionEvents(), + FriendTracker(), DefaultVolumeControls(), CallRecorder(), DisableMemoriesSnapFeed(), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/MixerStories.kt @@ -2,8 +2,8 @@ package me.rhunk.snapenhance.core.features.impl import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import me.rhunk.snapenhance.common.data.StoryData import me.rhunk.snapenhance.common.data.MixerStoryType +import me.rhunk.snapenhance.common.data.StoryData import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.features.Feature diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/ProfilePictureDownloader.kt @@ -9,9 +9,6 @@ import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.core.util.hook.HookStage -import me.rhunk.snapenhance.core.util.hook.Hooker -import java.nio.ByteBuffer class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { @SuppressLint("SetTextI18n") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/SessionEvents.kt @@ -1,359 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.experiments - -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 -import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID -import me.rhunk.snapenhance.nativelib.NativeLib -import java.lang.reflect.Method -import java.nio.ByteBuffer - -class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.INIT_SYNC) { - 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") - } - - 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) { - val conversationId = protoReader.getString(6) ?: return - - val presenceMap = conversationPresenceState.getOrPut(conversationId) { mutableMapOf() }.toMutableMap() - val userIds = mutableSetOf<String>() - - protoReader.eachBuffer(4) { - val participantUserId = getString(1)?.takeIf { it.contains(":") }?.substringBefore(":") ?: return@eachBuffer - userIds.add(participantUserId) - if (participantUserId == context.database.myUserId) return@eachBuffer - val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer - - presenceMap[participantUserId] = FriendPresenceState( - bitmojiPresent = stateMap[0], - typing = stateMap[4], - wasTyping = stateMap[5], - speaking = stateMap[6] && stateMap[4], - peeking = stateMap[8] - ) - } - - presenceMap.keys.filterNot { it in userIds }.forEach { presenceMap[it] = null } - - presenceMap.forEach { (userId, state) -> - val oldState = conversationPresenceState[conversationId]?.get(userId) - if (oldState != state) { - onConversationPresenceUpdate(conversationId, userId, oldState, state) - } - } - - conversationPresenceState[conversationId] = presenceMap - } - - private fun handleMessagingEvent(protoReader: ProtoReader) { - // read receipts - protoReader.followPath(12) { - val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath - - followPath(7) readReceipts@{ - val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@readReceipts - val serverMessageId = getVarInt(2, 2) ?: return@readReceipts - - onConversationMessagingEvent( - SessionMessageEvent( - SessionEventType.MESSAGE_READ_RECEIPTS, - conversationId, - senderId, - serverMessageId, - ) - ) - } - } - - protoReader.followPath(6, 2) { - 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)) { - onConversationMessagingEvent( - SessionMessageEvent( - SessionEventType.SNAP_OPENED, - conversationId, - senderId, - serverMessageId - ) - ) - } - - if (contains(13)) { - onConversationMessagingEvent( - SessionMessageEvent( - if (getVarInt(13, 1) == 2L) SessionEventType.SNAP_REPLAYED_TWICE else SessionEventType.SNAP_REPLAYED, - conversationId, - senderId, - serverMessageId - ) - ) - } - - if (contains(6) || contains(7)) { - onConversationMessagingEvent( - SessionMessageEvent( - if (contains(6)) SessionEventType.MESSAGE_SAVED else SessionEventType.MESSAGE_UNSAVED, - conversationId, - senderId, - serverMessageId - ) - ) - } - - if (contains(11) || contains(12)) { - onConversationMessagingEvent( - SessionMessageEvent( - if (contains(11)) SessionEventType.SNAP_SCREENSHOT else SessionEventType.SNAP_SCREEN_RECORD, - conversationId, - senderId, - serverMessageId, - ) - ) - } - - followPath(16) { - onConversationMessagingEvent( - SessionMessageEvent( - SessionEventType.MESSAGE_REACTION_ADD, conversationId, senderId, serverMessageId, reactionId = getVarInt(1, 1, 1)?.toInt() ?: -1 - ) - ) - } - - if (contains(17)) { - onConversationMessagingEvent( - SessionMessageEvent(SessionEventType.MESSAGE_REACTION_REMOVE, conversationId, senderId, serverMessageId) - ) - } - - followPath(8) { - onConversationMessagingEvent( - SessionMessageEvent(SessionEventType.MESSAGE_DELETED, conversationId, senderId, serverMessageId, messageData = getByteArray(1)) - ) - } - } - } - - override fun init() { - val sessionEventsConfig = context.config.experimental.sessionEvents - if (sessionEventsConfig.globalState != true) return - - if (sessionEventsConfig.allowRunningInBackground.get()) { - findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { - // prevent disabling events when the app is inactive - hook("appStateChanged", HookStage.BEFORE) { param -> - if (param.arg<Any>(0).toString() == "INACTIVE") param.setResult(null) - } - // allow events when a notification is received - hookConstructor(HookStage.AFTER) { param -> - methods.first { it.name == "appStateChanged" }.let { method -> - method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants.first { it.toString() == "ACTIVE" }) - } - } - } - } - - if (sessionEventsConfig.captureDuplexEvents.get()) { - val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply { - hook("onReceive", HookStage.BEFORE) { param -> - param.setResult(null) - - val byteBuffer = param.arg<ByteBuffer>(0) - val content = byteBuffer.let { - val bytes = ByteArray(it.limit()) - it.get(bytes) - bytes - } - val reader = ProtoReader(content) - reader.getString(1, 1)?.let { - val eventData = reader.followPath(1, 2) ?: return@let - if (it == "volatile") { - handleVolatileEvent(eventData) - return@hook - } - - if (it == "presence") { - handlePresenceEvent(eventData) - return@hook - } - } - handleMessagingEvent(reader) - } - hook("nativeDestroy", HookStage.BEFORE) { it.setResult(null) } - } - - - findClass("com.snapchat.client.messaging.Session").hook("create", HookStage.BEFORE) { param -> - if (!NativeLib.initialized) { - context.log.warn("Can't register duplex message handler, native lib not initialized") - return@hook - } - - val method = param.method() as Method - val duplexClient = method.parameterTypes.indexOfFirst { it.name.endsWith("DuplexClient") }.let { - param.arg<Any>(it) - } - val dispatchQueue = method.parameterTypes.indexOfFirst { it.name.endsWith("DispatchQueue") }.let { - param.arg<Any>(it) - } - for (channel in arrayOf("pcs", "mcs")) { - duplexClient::class.java.methods.first { - it.name == "registerHandler" - }.invoke( - duplexClient, - channel, - messageHandlerClass.declaredConstructors.first().also { it.isAccessible = true }.newInstance(-1), - dispatchQueue - ) - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/FriendTracker.kt @@ -0,0 +1,383 @@ +package me.rhunk.snapenhance.core.features.impl.spying + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.data.* +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +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 +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID +import me.rhunk.snapenhance.nativelib.NativeLib +import java.lang.reflect.Method +import java.nio.ByteBuffer + +class FriendTracker : Feature("Friend Tracker", loadParams = FeatureLoadParams.INIT_SYNC) { + private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state) + private val tracker by lazy { context.bridgeClient.getTracker() } + private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java).apply { + createNotificationChannel(NotificationChannel( + "friend_tracker", + "Friend Tracker", + NotificationManager.IMPORTANCE_DEFAULT + )) + } } + + private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? { + return runCatching { + 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) { + notificationManager.notify( + id, + Notification.Builder( + context.androidContext, + "friend_tracker" + ) + .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") + } + + private fun dispatchEvents( + eventType: TrackerEventType, + conversationId: String, + userId: String, + extras: String = "" + ) { + val feedEntry = context.database.getFeedEntryByConversationId(conversationId) + val conversationName = feedEntry?.feedDisplayName ?: "DMs" + val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown" + + context.log.verbose("$authorName $eventType in $conversationName") + + getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) -> + if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach + if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach + if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach + if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach + + context.log.verbose("dispatching $action for $eventType in $conversationName") + + when (action) { + TrackerRuleAction.PUSH_NOTIFICATION -> { + if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach + sendInfoNotification(text = "$authorName $eventType in $conversationName") + } + TrackerRuleAction.IN_APP_NOTIFICATION -> context.inAppOverlay.showStatusToast( + icon = Icons.Default.Info, + text = "$authorName $eventType in $conversationName" + ) + TrackerRuleAction.LOG -> context.bridgeClient.getMessageLogger().logTrackerEvent( + conversationId, + conversationName, + context.database.getConversationType(conversationId) == 1, + authorName, + userId, + eventType.key, + extras + ) + else -> {} + } + } + } + + private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) { + context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState") + + 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 + + dispatchEvents(eventType, conversationId, userId) + } + + private fun onConversationMessagingEvent(event: SessionEvent) { + context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}") + + val 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.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED + SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED + SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED + SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE + SessionEventType.SNAP_SCREENSHOT -> TrackerEventType.SNAP_SCREENSHOT + SessionEventType.SNAP_SCREEN_RECORD -> TrackerEventType.SNAP_SCREEN_RECORD + else -> return + } + + val conversationMessage by lazy { + (event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) } + } + + dispatchEvents(eventType, event.conversationId, event.authorUserId, extras = conversationMessage?.takeIf { + eventType == TrackerEventType.MESSAGE_READ || + eventType == TrackerEventType.MESSAGE_REACTION_ADD || + eventType == TrackerEventType.MESSAGE_REACTION_REMOVE || + eventType == TrackerEventType.MESSAGE_DELETED || + eventType == TrackerEventType.MESSAGE_SAVED || + eventType == TrackerEventType.MESSAGE_UNSAVED || + eventType == TrackerEventType.MESSAGE_EDITED + }?.contentType?.let { ContentType.fromId(it).name } ?: "") + } + + private fun handlePresenceEvent(protoReader: ProtoReader) { + val conversationId = protoReader.getString(6) ?: return + + val presenceMap = conversationPresenceState.getOrPut(conversationId) { mutableMapOf() }.toMutableMap() + val userIds = mutableSetOf<String>() + + protoReader.eachBuffer(4) { + val participantUserId = getString(1)?.takeIf { it.contains(":") }?.substringBefore(":") ?: return@eachBuffer + userIds.add(participantUserId) + if (participantUserId == context.database.myUserId) return@eachBuffer + val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer + + presenceMap[participantUserId] = FriendPresenceState( + bitmojiPresent = stateMap[0], + typing = stateMap[4], + wasTyping = stateMap[5], + speaking = stateMap[6] && stateMap[4], + peeking = stateMap[8] + ) + } + + presenceMap.keys.filterNot { it in userIds }.forEach { presenceMap[it] = null } + + presenceMap.forEach { (userId, state) -> + val oldState = conversationPresenceState[conversationId]?.get(userId) + if (oldState != state) { + onConversationPresenceUpdate(conversationId, userId, oldState, state) + } + } + + conversationPresenceState[conversationId] = presenceMap + } + + private fun handleMessagingEvent(protoReader: ProtoReader) { + // read receipts + protoReader.followPath(12) { + val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath + + followPath(7) readReceipts@{ + val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@readReceipts + val serverMessageId = getVarInt(2, 2) ?: return@readReceipts + + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_READ_RECEIPTS, + conversationId, + senderId, + serverMessageId, + ) + ) + } + } + + protoReader.followPath(13, 1, 4) { + val serverMessageId = getVarInt(1) ?: return@followPath + val senderId = getByteArray(2, 1) ?: return@followPath + val conversationId = getByteArray(3, 1, 1, 1) ?: return@followPath + + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_EDITED, + SnapUUID(conversationId).toString(), + SnapUUID(senderId).toString(), + serverMessageId + ) + ) + } + + protoReader.followPath(6, 2) { + val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath + val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath + val serverMessageId = getVarInt(2) ?: return@followPath + + if (contains(4)) { + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.SNAP_OPENED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(13)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (getVarInt(13, 1) == 2L) SessionEventType.SNAP_REPLAYED_TWICE else SessionEventType.SNAP_REPLAYED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(6) || contains(7)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (contains(6)) SessionEventType.MESSAGE_SAVED else SessionEventType.MESSAGE_UNSAVED, + conversationId, + senderId, + serverMessageId + ) + ) + } + + if (contains(11) || contains(12)) { + onConversationMessagingEvent( + SessionMessageEvent( + if (contains(11)) SessionEventType.SNAP_SCREENSHOT else SessionEventType.SNAP_SCREEN_RECORD, + conversationId, + senderId, + serverMessageId, + ) + ) + } + + followPath(16) { + onConversationMessagingEvent( + SessionMessageEvent( + SessionEventType.MESSAGE_REACTION_ADD, conversationId, senderId, serverMessageId, reactionId = getVarInt(1, 1, 1)?.toInt() ?: -1 + ) + ) + } + + if (contains(17)) { + onConversationMessagingEvent( + SessionMessageEvent(SessionEventType.MESSAGE_REACTION_REMOVE, conversationId, senderId, serverMessageId) + ) + } + + followPath(8) { + onConversationMessagingEvent( + SessionMessageEvent(SessionEventType.MESSAGE_DELETED, conversationId, senderId, serverMessageId, messageData = getByteArray(1)) + ) + } + } + } + + override fun init() { + val sessionEventsConfig = context.config.friendTracker + if (sessionEventsConfig.globalState != true) return + + if (sessionEventsConfig.allowRunningInBackground.get()) { + findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { + // prevent disabling events when the app is inactive + hook("appStateChanged", HookStage.BEFORE) { param -> + if (param.arg<Any>(0).toString() == "INACTIVE") param.setResult(null) + } + // allow events when a notification is received + hookConstructor(HookStage.AFTER) { param -> + methods.first { it.name == "appStateChanged" }.let { method -> + method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants.first { it.toString() == "ACTIVE" }) + } + } + } + } + + if (sessionEventsConfig.recordMessagingEvents.get()) { + val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply { + hook("onReceive", HookStage.BEFORE) { param -> + param.setResult(null) + + val byteBuffer = param.arg<ByteBuffer>(0) + val content = byteBuffer.let { + val bytes = ByteArray(it.limit()) + it.get(bytes) + bytes + } + val reader = ProtoReader(content) + reader.getString(1, 1)?.let { + val eventData = reader.followPath(1, 2) ?: return@let + if (it == "volatile") { + handleVolatileEvent(eventData) + return@hook + } + + if (it == "presence") { + handlePresenceEvent(eventData) + return@hook + } + } + handleMessagingEvent(reader) + } + hook("nativeDestroy", HookStage.BEFORE) { it.setResult(null) } + } + + + findClass("com.snapchat.client.messaging.Session").hook("create", HookStage.BEFORE) { param -> + if (!NativeLib.initialized) { + context.log.warn("Can't register duplex message handler, native lib not initialized") + return@hook + } + + val method = param.method() as Method + val duplexClient = method.parameterTypes.indexOfFirst { it.name.endsWith("DuplexClient") }.let { + param.arg<Any>(it) + } + val dispatchQueue = method.parameterTypes.indexOfFirst { it.name.endsWith("DispatchQueue") }.let { + param.arg<Any>(it) + } + for (channel in arrayOf("pcs", "mcs")) { + duplexClient::class.java.methods.first { + it.name == "registerHandler" + }.invoke( + duplexClient, + channel, + messageHandlerClass.declaredConstructors.first().also { it.isAccessible = true }.newInstance(-1), + dispatchQueue + ) + } + } + } + } +}+ \ No newline at end of file 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 @@ -119,8 +119,10 @@ class MessageLogger : Feature("MessageLogger", if (!isMessageDeleted) { if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe - if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe - fetchedMessages.add(uniqueMessageIdentifier) + if (event.message.messageMetadata?.isEdited != true) { + if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe + fetchedMessages.add(uniqueMessageIdentifier) + } threadPool.execute { try { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt @@ -19,12 +19,10 @@ abstract class AbstractWrapper( inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) { @Suppress("UNCHECKED_CAST") operator fun getValue(obj: Any, property: KProperty<*>): T? { - val value = runCatching { XposedHelpers.getObjectField(instance, fieldName) }.getOrNull() - return if (mapper != null) { - mapper.invoke(value) - } else { - value as? T - } + return runCatching { + val value = XposedHelpers.getObjectField(instance, fieldName) + mapper?.invoke(value) ?: value as? T + }.getOrNull() } operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/BCryptClassMapper.kt @@ -1,10 +1,10 @@ package me.rhunk.snapenhance.mapper.impl +import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.getStaticConstructor import me.rhunk.snapenhance.mapper.ext.isFinal -import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload class BCryptClassMapper : AbstractClassMapper("BCryptClass") { val classReference = classReference("class") diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/CompositeConfigurationProviderMapper.kt @@ -1,14 +1,14 @@ package me.rhunk.snapenhance.mapper.impl +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.ext.findConstString import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString import me.rhunk.snapenhance.mapper.ext.isEnum -import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction21c -import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c -import com.android.tools.smali.dexlib2.iface.reference.FieldReference -import com.android.tools.smali.dexlib2.iface.reference.MethodReference import java.lang.reflect.Modifier class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/MediaQualityLevelProviderMapper.kt @@ -1,11 +1,11 @@ package me.rhunk.snapenhance.mapper.impl +import com.android.tools.smali.dexlib2.AccessFlags import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.ext.getClassName import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString import me.rhunk.snapenhance.mapper.ext.isAbstract import me.rhunk.snapenhance.mapper.ext.isEnum -import com.android.tools.smali.dexlib2.AccessFlags class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") { val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider") diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/impl/OperaViewerParamsMapper.kt @@ -1,11 +1,11 @@ package me.rhunk.snapenhance.mapper.impl import com.android.tools.smali.dexlib2.iface.Method +import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c +import com.android.tools.smali.dexlib2.iface.reference.MethodReference import me.rhunk.snapenhance.mapper.AbstractClassMapper import me.rhunk.snapenhance.mapper.ext.findConstString import me.rhunk.snapenhance.mapper.ext.getClassName -import com.android.tools.smali.dexlib2.iface.instruction.formats.Instruction35c -import com.android.tools.smali.dexlib2.iface.reference.MethodReference class OperaViewerParamsMapper : AbstractClassMapper("OperaViewerParams") { val classReference = classReference("class")