mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-29 13:00:17 +02:00
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>
This commit is contained in:
parent
43fb83ab5c
commit
dadec3d278
@ -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"
|
||||
|
@ -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?
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
|
||||
|
@ -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> {
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.messaging
|
||||
package me.rhunk.snapenhance
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
@ -10,11 +10,10 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.R
|
||||
import me.rhunk.snapenhance.RemoteSideContext
|
||||
import me.rhunk.snapenhance.SharedContextHolder
|
||||
import me.rhunk.snapenhance.bridge.ForceStartActivity
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.storage.getFriendStreaks
|
||||
import me.rhunk.snapenhance.storage.getFriends
|
||||
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@ -56,8 +55,8 @@ class StreaksReminder(
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
|
||||
val notifyFriendList = remoteSideContext.modDatabase.getFriends()
|
||||
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) }
|
||||
val notifyFriendList = remoteSideContext.database.getFriends()
|
||||
.associateBy { remoteSideContext.database.getFriendStreaks(it.userId) }
|
||||
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
|
||||
|
||||
val notificationManager = getNotificationManager(ctx)
|
@ -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) }
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
181
app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
Normal file
181
app/src/main/kotlin/me/rhunk/snapenhance/storage/Messaging.kt
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
197
app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
Normal file
197
app/src/main/kotlin/me/rhunk/snapenhance/storage/Tracker.kt
Normal file
@ -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
|
||||
}
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }) {
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -31,6 +31,7 @@ import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.rhunk.snapenhance.LogReader
|
||||
import me.rhunk.snapenhance.common.logger.LogChannel
|
||||
@ -64,8 +65,12 @@ class HomeLogs : Routes.Route() {
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
context.log.clearLogs()
|
||||
navigate()
|
||||
context.coroutineScope.launch {
|
||||
context.log.clearLogs()
|
||||
}
|
||||
routes.navController.navigate(routeInfo.id) {
|
||||
popUpTo(routeInfo.id) { inclusive = true }
|
||||
}
|
||||
showDropDown = false
|
||||
}, text = {
|
||||
Text(translation["clear_logs_button"])
|
||||
@ -148,63 +153,73 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
items(lineCount) { index ->
|
||||
val logLine = remember(index) { logReader?.getLogLine(index) } ?: return@items
|
||||
val logLine by remember(index) {
|
||||
mutableStateOf(runBlocking(Dispatchers.IO) {
|
||||
logReader?.getLogLine(index)
|
||||
})
|
||||
}
|
||||
var expand by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setText(AnnotatedString(logLine.message))
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
expand = !expand
|
||||
}
|
||||
)
|
||||
}) {
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (!expand) {
|
||||
Icon(
|
||||
imageVector = when (logLine.logLevel) {
|
||||
LogLevel.DEBUG -> Icons.Outlined.BugReport
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
logLine?.let { line ->
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(
|
||||
line.message
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
contentDescription = null,
|
||||
onTap = {
|
||||
expand = !expand
|
||||
}
|
||||
)
|
||||
}) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (!expand) {
|
||||
Icon(
|
||||
imageVector = when (line.logLevel) {
|
||||
LogLevel.DEBUG -> Icons.Outlined.BugReport
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
else -> Icons.Outlined.Info
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = LogChannel.fromChannel(line.tag)?.shortName ?: line.tag,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = line.dateTime,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
fontWeight = FontWeight.Light,
|
||||
text = line.message.trimIndent(),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = logLine.dateTime,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
fontSize = 10.sp
|
||||
maxLines = if (expand) Int.MAX_VALUE else 6,
|
||||
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
|
||||
softWrap = !expand,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = logLine.message.trimIndent(),
|
||||
fontSize = 10.sp,
|
||||
maxLines = if (expand) Int.MAX_VALUE else 6,
|
||||
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
|
||||
softWrap = !expand,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,50 +151,48 @@ class HomeRoot : Routes.Route() {
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "An xposed module made to enhance your Snapchat experience",
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
15.dp, Alignment.CenterHorizontally
|
||||
), modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
LinkIcon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(32.dp).clickable {
|
||||
context.activity?.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(
|
||||
intArrayOf(101,99,110,97,104,110,69,112,97,110,83,47,107,110,117,104,114,47,109,111,99,46,98,117,104,116,105,103,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed()
|
||||
)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
)
|
||||
}
|
||||
dataArray = intArrayOf(
|
||||
101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83, 47, 107, 110,
|
||||
117, 104, 114, 47, 109, 111, 99, 46, 98, 117, 104, 116, 105,
|
||||
103, 47, 58, 115, 112, 116, 116, 104
|
||||
)
|
||||
)
|
||||
Icon(
|
||||
|
||||
LinkIcon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(32.dp).clickable {
|
||||
context.activity?.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(
|
||||
intArrayOf(101,99,110,97,104,110,101,112,97,110,115,47,101,109,46,116,47,47,58,115,112,116,116,104).map { it.toChar() }.joinToString("").reversed()
|
||||
)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
)
|
||||
}
|
||||
dataArray = intArrayOf(
|
||||
101, 99, 110, 97, 104, 110, 101, 112, 97, 110, 115, 47, 101,
|
||||
109, 46, 116, 47, 47, 58, 115, 112, 116, 116, 104
|
||||
)
|
||||
)
|
||||
|
||||
LinkIcon(
|
||||
size = 36.dp,
|
||||
modifier = Modifier.offset(y = (-2).dp),
|
||||
imageVector = Icons.AutoMirrored.Default.Help,
|
||||
dataArray = intArrayOf(
|
||||
105, 107, 105, 119, 47, 101, 99, 110, 97, 104, 110, 69, 112, 97,
|
||||
110, 83, 47, 107, 110, 117, 104, 114, 47, 109, 111, 99, 46, 98,
|
||||
117, 104, 116, 105, 103, 47, 47, 58, 115, 112, 116, 116, 104
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
|
||||
context.database.getQuickTiles()
|
||||
}
|
||||
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
|
||||
if (!BuildConfig.DEBUG) Updater.checkForLatestRelease() else null
|
||||
}
|
||||
|
||||
if (latestUpdate != null) {
|
||||
@ -209,7 +205,7 @@ class HomeRoot : Routes.Route() {
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
){
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -223,17 +219,16 @@ class HomeRoot : Routes.Route() {
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
fontSize = 12.sp,
|
||||
text = translation.format("update_content", "version" to (latestUpdate?.versionName ?: "unknown")),
|
||||
lineHeight = 20.sp
|
||||
fontSize = 12.sp, text = translation.format(
|
||||
"update_content",
|
||||
"version" to (latestUpdate?.versionName ?: "unknown")
|
||||
), lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
Button(onClick = {
|
||||
context.activity?.startActivity(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(latestUpdate?.releaseUrl)
|
||||
}
|
||||
)
|
||||
context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(latestUpdate?.releaseUrl)
|
||||
})
|
||||
}, modifier = Modifier.height(40.dp)) {
|
||||
Text(text = translation["update_button"])
|
||||
}
|
||||
@ -241,38 +236,93 @@ class HomeRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var installationSummary by remember { mutableStateOf(null as InstallationSummary?) }
|
||||
var showQuickActionsMenu by remember { mutableStateOf(false) }
|
||||
|
||||
fun updateInstallationSummary(scope: CoroutineScope) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
installationSummary = context.installationSummary
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 30.dp, top = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Quick Actions", fontSize = 20.sp, modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showQuickActionsMenu = !showQuickActionsMenu },
|
||||
) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||
}
|
||||
runCatching {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
latestUpdate = Updater.checkForLatestRelease()
|
||||
DropdownMenu(
|
||||
expanded = showQuickActionsMenu,
|
||||
onDismissRequest = { showQuickActionsMenu = false }
|
||||
) {
|
||||
cards.forEach { (card, _) ->
|
||||
fun toggle(state: Boolean? = null) {
|
||||
if (state?.let { !it } ?: selectedTiles.contains(card.first)) {
|
||||
selectedTiles.remove(card.first)
|
||||
} else {
|
||||
selectedTiles.add(0, card.first)
|
||||
}
|
||||
context.coroutineScope.launch {
|
||||
context.database.setQuickTiles(selectedTiles)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenuItem(onClick = { toggle() }, text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(all = 5.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selectedTiles.contains(card.first),
|
||||
onCheckedChange = {
|
||||
toggle(it)
|
||||
}
|
||||
)
|
||||
Text(text = card.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.padding(all = cardMargin)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
remember(selectedTiles.size, context.translation.loadedLocale) { selectedTiles.mapNotNull {
|
||||
cards.entries.find { entry -> entry.key.first == it }
|
||||
} }.forEach { (card, action) ->
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.size(105.dp)
|
||||
.weight(1f)
|
||||
.clickable { action() }
|
||||
.padding(all = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(all = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = card.second, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
Text(
|
||||
lineHeight = 16.sp, text = card.first, fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast("SnapEnhance failed to check for updates: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
updateInstallationSummary(coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateInstallationSummary(coroutineScope)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
installationSummary?.let { SummaryCards(installationSummary = it) }
|
||||
}
|
||||
}
|
||||
}
|
@ -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}")
|
||||
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) }
|
||||
|
@ -17,13 +17,19 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.data.FriendStreaks
|
||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||
import me.rhunk.snapenhance.common.data.MessagingRuleType
|
||||
import me.rhunk.snapenhance.common.data.SocialScope
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
|
||||
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
|
||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.storage.*
|
||||
import me.rhunk.snapenhance.ui.manager.Routes
|
||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
||||
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||
import me.rhunk.snapenhance.ui.util.Dialog
|
||||
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ -32,10 +38,10 @@ class ManageScope: Routes.Route() {
|
||||
|
||||
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
|
||||
SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
|
||||
SocialScope.FRIEND -> context.database.deleteFriend(id)
|
||||
SocialScope.GROUP -> context.database.deleteGroup(id)
|
||||
}
|
||||
context.modDatabase.executeAsync {
|
||||
context.database.executeAsync {
|
||||
coroutineScope.launch {
|
||||
routes.navController.popBackStack()
|
||||
}
|
||||
@ -79,48 +85,98 @@ class ManageScope: Routes.Route() {
|
||||
val id = navBackStackEntry.arguments?.getString("id")!!
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxSize()
|
||||
) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> Friend(id)
|
||||
SocialScope.GROUP -> Group(id)
|
||||
var hasScope by remember {
|
||||
mutableStateOf(null as Boolean?)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val rules = context.modDatabase.getRules(id)
|
||||
|
||||
SectionTitle(translation["rules_title"])
|
||||
|
||||
ContentCard {
|
||||
MessagingRuleType.entries.forEach { ruleType ->
|
||||
var ruleEnabled by remember {
|
||||
mutableStateOf(rules.any { it.key == ruleType.key })
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
var streaks by remember { mutableStateOf(null as FriendStreaks?) }
|
||||
val friend by rememberAsyncMutableState(null) {
|
||||
context.database.getFriendInfo(id)?.also {
|
||||
streaks = context.database.getFriendStreaks(id)
|
||||
}.also {
|
||||
hasScope = it != null
|
||||
}
|
||||
}
|
||||
|
||||
val ruleState = context.config.root.rules.getRuleState(ruleType)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(all = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (ruleType.listMode && ruleState != null) {
|
||||
context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"]
|
||||
} else context.translation["rules.properties.${ruleType.key}.name"],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 5.dp, end = 5.dp)
|
||||
)
|
||||
Switch(checked = ruleEnabled,
|
||||
enabled = if (ruleType.listMode) ruleState != null else true,
|
||||
onCheckedChange = {
|
||||
context.modDatabase.setRule(id, ruleType.key, it)
|
||||
ruleEnabled = it
|
||||
}
|
||||
)
|
||||
friend?.let {
|
||||
Friend(id, it, streaks)
|
||||
}
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
val group by rememberAsyncMutableState(null) {
|
||||
context.database.getGroupInfo(id).also {
|
||||
hasScope = it != null
|
||||
}
|
||||
}
|
||||
group?.let {
|
||||
Group(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasScope == true) {
|
||||
RulesCard(id)
|
||||
}
|
||||
if (hasScope == false) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = translation["not_found"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun RulesCard(
|
||||
id: String
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val rules = rememberAsyncMutableStateList(listOf()) {
|
||||
context.database.getRules(id)
|
||||
}
|
||||
|
||||
SectionTitle(translation["rules_title"])
|
||||
|
||||
ContentCard {
|
||||
MessagingRuleType.entries.forEach { ruleType ->
|
||||
var ruleEnabled by remember(rules.size) {
|
||||
mutableStateOf(rules.any { it.key == ruleType.key })
|
||||
}
|
||||
|
||||
val ruleState = context.config.root.rules.getRuleState(ruleType)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(all = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (ruleType.listMode && ruleState != null) {
|
||||
context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"]
|
||||
} else context.translation["rules.properties.${ruleType.key}.name"],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 5.dp, end = 5.dp)
|
||||
)
|
||||
Switch(checked = ruleEnabled,
|
||||
enabled = if (ruleType.listMode) ruleState != null else true,
|
||||
onCheckedChange = {
|
||||
context.database.setRule(id, ruleType.key, it)
|
||||
ruleEnabled = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,17 +241,11 @@ class ManageScope: Routes.Route() {
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
private fun Friend(id: String) {
|
||||
//fetch the friend from the database
|
||||
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
|
||||
Text(text = translation["not_found"])
|
||||
return
|
||||
}
|
||||
|
||||
val streaks = remember {
|
||||
context.modDatabase.getFriendStreaks(id)
|
||||
}
|
||||
|
||||
private fun Friend(
|
||||
id: String,
|
||||
friend: MessagingFriendInfo,
|
||||
streaks: FriendStreaks?
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
@ -275,7 +325,7 @@ class ManageScope: Routes.Route() {
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
Switch(checked = shouldNotify, onCheckedChange = {
|
||||
context.modDatabase.setFriendStreaksNotify(id, it)
|
||||
context.database.setFriendStreaksNotify(id, it)
|
||||
shouldNotify = it
|
||||
})
|
||||
}
|
||||
@ -286,7 +336,9 @@ class ManageScope: Routes.Route() {
|
||||
|
||||
if (context.config.root.experimental.e2eEncryption.globalState == true) {
|
||||
SectionTitle(translation["e2ee_title"])
|
||||
var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))}
|
||||
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
|
||||
context.e2eeImplementation.friendKeyExists(friend.userId)
|
||||
}
|
||||
var importDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (importDialog) {
|
||||
@ -302,8 +354,11 @@ class ManageScope: Routes.Route() {
|
||||
return@runCatching
|
||||
}
|
||||
|
||||
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
|
||||
context.longToast("Successfully imported key")
|
||||
context.coroutineScope.launch {
|
||||
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
|
||||
context.longToast("Successfully imported key")
|
||||
}
|
||||
|
||||
hasSecretKey = true
|
||||
}.onFailure {
|
||||
context.longToast("Failed to import key: ${it.message}")
|
||||
@ -320,20 +375,22 @@ class ManageScope: Routes.Route() {
|
||||
) {
|
||||
if (hasSecretKey) {
|
||||
OutlinedButton(onClick = {
|
||||
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton)
|
||||
//TODO: fingerprint auth
|
||||
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
type = "text/plain"
|
||||
}, "").apply {
|
||||
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
||||
Intent().apply {
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
||||
})
|
||||
)
|
||||
})
|
||||
context.coroutineScope.launch {
|
||||
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
|
||||
//TODO: fingerprint auth
|
||||
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
type = "text/plain"
|
||||
}, "").apply {
|
||||
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
||||
Intent().apply {
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
text = "Export Base64",
|
||||
@ -355,13 +412,7 @@ class ManageScope: Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Group(id: String) {
|
||||
//fetch the group from the database
|
||||
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
|
||||
Text(text = translation["not_found"])
|
||||
return
|
||||
}
|
||||
|
||||
private fun Group(group: MessagingGroupInfo) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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) }
|
||||
|
@ -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)
|
||||
}
|
@ -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() }
|
||||
}
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -1,8 +1,11 @@
|
||||
package me.rhunk.snapenhance.core.features.impl.experiments
|
||||
package me.rhunk.snapenhance.core.features.impl.spying
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import me.rhunk.snapenhance.common.Constants
|
||||
import me.rhunk.snapenhance.common.data.*
|
||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||
@ -13,14 +16,22 @@ import me.rhunk.snapenhance.core.features.impl.messaging.Messaging
|
||||
import me.rhunk.snapenhance.core.util.hook.HookStage
|
||||
import me.rhunk.snapenhance.core.util.hook.hook
|
||||
import me.rhunk.snapenhance.core.util.hook.hookConstructor
|
||||
import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID
|
||||
import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID
|
||||
import me.rhunk.snapenhance.nativelib.NativeLib
|
||||
import java.lang.reflect.Method
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||
class FriendTracker : Feature("Friend Tracker", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||
private val conversationPresenceState = mutableMapOf<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 {
|
||||
@ -32,14 +43,14 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun isInConversation(conversationId: String) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId
|
||||
private fun isInConversation(conversationId: String?) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId
|
||||
|
||||
private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) {
|
||||
context.androidContext.getSystemService(NotificationManager::class.java).notify(
|
||||
notificationManager.notify(
|
||||
id,
|
||||
Notification.Builder(
|
||||
context.androidContext,
|
||||
"general_group_generic_push_noisy_generic_push_B~LVSD2"
|
||||
Notification.Builder(
|
||||
context.androidContext,
|
||||
"friend_tracker"
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setAutoCancel(true)
|
||||
@ -62,6 +73,49 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
context.log.verbose("volatile event\n$protoReader")
|
||||
}
|
||||
|
||||
private fun dispatchEvents(
|
||||
eventType: TrackerEventType,
|
||||
conversationId: String,
|
||||
userId: String,
|
||||
extras: String = ""
|
||||
) {
|
||||
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
|
||||
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
|
||||
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
|
||||
|
||||
context.log.verbose("$authorName $eventType in $conversationName")
|
||||
|
||||
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) ->
|
||||
if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach
|
||||
if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach
|
||||
if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach
|
||||
if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach
|
||||
|
||||
context.log.verbose("dispatching $action for $eventType in $conversationName")
|
||||
|
||||
when (action) {
|
||||
TrackerRuleAction.PUSH_NOTIFICATION -> {
|
||||
if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach
|
||||
sendInfoNotification(text = "$authorName $eventType in $conversationName")
|
||||
}
|
||||
TrackerRuleAction.IN_APP_NOTIFICATION -> context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.Info,
|
||||
text = "$authorName $eventType in $conversationName"
|
||||
)
|
||||
TrackerRuleAction.LOG -> context.bridgeClient.getMessageLogger().logTrackerEvent(
|
||||
conversationId,
|
||||
conversationName,
|
||||
context.database.getConversationType(conversationId) == 1,
|
||||
authorName,
|
||||
userId,
|
||||
eventType.key,
|
||||
extras
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
|
||||
context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState")
|
||||
|
||||
@ -75,40 +129,11 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
else -> null
|
||||
} ?: return
|
||||
|
||||
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
|
||||
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
|
||||
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
|
||||
|
||||
context.log.verbose("$authorName $eventType in $conversationName")
|
||||
|
||||
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.apply {
|
||||
if (hasFlags(TrackerFlags.APP_IS_ACTIVE) && context.isMainActivityPaused) return
|
||||
if (hasFlags(TrackerFlags.APP_IS_INACTIVE) && !context.isMainActivityPaused) return
|
||||
if (hasFlags(TrackerFlags.IS_IN_CONVERSATION) && !isInConversation(conversationId)) return
|
||||
if (hasFlags(TrackerFlags.NOTIFY)) sendInfoNotification(text = "$authorName $eventType in $conversationName")
|
||||
if (hasFlags(TrackerFlags.LOG)) {
|
||||
context.bridgeClient.getMessageLogger().logTrackerEvent(
|
||||
conversationId,
|
||||
conversationName,
|
||||
context.database.getConversationType(conversationId) == 1,
|
||||
authorName,
|
||||
userId,
|
||||
eventType.key,
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
dispatchEvents(eventType, conversationId, userId)
|
||||
}
|
||||
|
||||
private fun onConversationMessagingEvent(event: SessionEvent) {
|
||||
context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}")
|
||||
val isConversationGroup = context.database.getConversationType(event.conversationId) == 1
|
||||
val authorName = context.database.getFriendInfo(event.authorUserId)?.mutableUsername ?: "Unknown"
|
||||
val conversationName = context.database.getFeedEntryByConversationId(event.conversationId)?.feedDisplayName ?: "DMs"
|
||||
|
||||
val conversationMessage by lazy {
|
||||
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
|
||||
}
|
||||
|
||||
val eventType = when(event.type) {
|
||||
SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ
|
||||
@ -117,6 +142,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
SessionEventType.MESSAGE_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE
|
||||
SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED
|
||||
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
|
||||
SessionEventType.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED
|
||||
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
|
||||
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
|
||||
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
|
||||
@ -125,36 +151,19 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
else -> return
|
||||
}
|
||||
|
||||
val messageEvents = arrayOf(
|
||||
TrackerEventType.MESSAGE_READ,
|
||||
TrackerEventType.MESSAGE_DELETED,
|
||||
TrackerEventType.MESSAGE_REACTION_ADD,
|
||||
TrackerEventType.MESSAGE_REACTION_REMOVE,
|
||||
TrackerEventType.MESSAGE_SAVED,
|
||||
TrackerEventType.MESSAGE_UNSAVED
|
||||
)
|
||||
|
||||
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(event.conversationId, event.authorUserId) }?.apply {
|
||||
if (messageEvents.contains(eventType) && conversationMessage?.senderId == context.database.myUserId) return
|
||||
|
||||
if (hasFlags(TrackerFlags.APP_IS_ACTIVE) && context.isMainActivityPaused) return
|
||||
if (hasFlags(TrackerFlags.APP_IS_INACTIVE) && !context.isMainActivityPaused) return
|
||||
if (hasFlags(TrackerFlags.IS_IN_CONVERSATION) && !isInConversation(event.conversationId)) return
|
||||
if (hasFlags(TrackerFlags.NOTIFY)) sendInfoNotification(text = "$authorName $eventType in $conversationName")
|
||||
if (hasFlags(TrackerFlags.LOG)) {
|
||||
context.bridgeClient.getMessageLogger().logTrackerEvent(
|
||||
event.conversationId,
|
||||
conversationName,
|
||||
isConversationGroup,
|
||||
authorName,
|
||||
event.authorUserId,
|
||||
eventType.key,
|
||||
messageEvents.takeIf { it.contains(eventType) }?.let {
|
||||
conversationMessage?.contentType?.let { ContentType.fromId(it) } ?.name
|
||||
} ?: ""
|
||||
)
|
||||
}
|
||||
val conversationMessage by lazy {
|
||||
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
|
||||
}
|
||||
|
||||
dispatchEvents(eventType, event.conversationId, event.authorUserId, extras = conversationMessage?.takeIf {
|
||||
eventType == TrackerEventType.MESSAGE_READ ||
|
||||
eventType == TrackerEventType.MESSAGE_REACTION_ADD ||
|
||||
eventType == TrackerEventType.MESSAGE_REACTION_REMOVE ||
|
||||
eventType == TrackerEventType.MESSAGE_DELETED ||
|
||||
eventType == TrackerEventType.MESSAGE_SAVED ||
|
||||
eventType == TrackerEventType.MESSAGE_UNSAVED ||
|
||||
eventType == TrackerEventType.MESSAGE_EDITED
|
||||
}?.contentType?.let { ContentType.fromId(it).name } ?: "")
|
||||
}
|
||||
|
||||
private fun handlePresenceEvent(protoReader: ProtoReader) {
|
||||
@ -210,6 +219,21 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
}
|
||||
}
|
||||
|
||||
protoReader.followPath(13, 1, 4) {
|
||||
val serverMessageId = getVarInt(1) ?: return@followPath
|
||||
val senderId = getByteArray(2, 1) ?: return@followPath
|
||||
val conversationId = getByteArray(3, 1, 1, 1) ?: return@followPath
|
||||
|
||||
onConversationMessagingEvent(
|
||||
SessionMessageEvent(
|
||||
SessionEventType.MESSAGE_EDITED,
|
||||
SnapUUID(conversationId).toString(),
|
||||
SnapUUID(senderId).toString(),
|
||||
serverMessageId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
protoReader.followPath(6, 2) {
|
||||
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
||||
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
||||
@ -282,7 +306,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
val sessionEventsConfig = context.config.experimental.sessionEvents
|
||||
val sessionEventsConfig = context.config.friendTracker
|
||||
if (sessionEventsConfig.globalState != true) return
|
||||
|
||||
if (sessionEventsConfig.allowRunningInBackground.get()) {
|
||||
@ -300,7 +324,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionEventsConfig.captureDuplexEvents.get()) {
|
||||
if (sessionEventsConfig.recordMessagingEvents.get()) {
|
||||
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
|
||||
hook("onReceive", HookStage.BEFORE) { param ->
|
||||
param.setResult(null)
|
@ -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 {
|
||||
|
@ -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?) {
|
||||
|
@ -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")
|
||||
|
@ -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") {
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
Loading…
x
Reference in New Issue
Block a user