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:excludeFromRecents="true"
|
||||||
android:exported="true" />
|
android:exported="true" />
|
||||||
|
|
||||||
<receiver android:name=".messaging.StreaksReminder" />
|
<receiver android:name=".StreaksReminder" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
@ -2,6 +2,8 @@ package me.rhunk.snapenhance
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.GsonBuilder
|
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.data.FileType
|
||||||
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
import me.rhunk.snapenhance.common.logger.AbstractLogger
|
||||||
import me.rhunk.snapenhance.common.logger.LogChannel
|
import me.rhunk.snapenhance.common.logger.LogChannel
|
||||||
@ -70,33 +72,39 @@ class LogReader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun incrementLineCount() {
|
fun incrementLineCount() {
|
||||||
randomAccessFile.seek(randomAccessFile.length())
|
synchronized(randomAccessFile) {
|
||||||
startLineIndexes.add(randomAccessFile.filePointer + 1)
|
randomAccessFile.seek(randomAccessFile.length())
|
||||||
lineCount++
|
startLineIndexes.add(randomAccessFile.filePointer + 1)
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryLineCount(): Int {
|
private fun queryLineCount(): Int {
|
||||||
randomAccessFile.seek(0)
|
synchronized(randomAccessFile) {
|
||||||
var lineCount = 0
|
randomAccessFile.seek(0)
|
||||||
var lastPointer: Long
|
var lineCount = 0
|
||||||
var line: String?
|
var lastPointer: Long
|
||||||
|
var line: String?
|
||||||
|
|
||||||
while (randomAccessFile.also {
|
while (randomAccessFile.also {
|
||||||
lastPointer = it.filePointer
|
lastPointer = it.filePointer
|
||||||
}.readLine().also { line = it } != null) {
|
}.readLine().also { line = it } != null) {
|
||||||
if (line?.startsWith('|') == true) {
|
if (line?.startsWith('|') == true) {
|
||||||
lineCount++
|
lineCount++
|
||||||
startLineIndexes.add(lastPointer + 1)
|
startLineIndexes.add(lastPointer + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return lineCount
|
return lineCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLine(index: Int): String? {
|
private fun getLine(index: Int): String? {
|
||||||
if (index <= 0 || index > lineCount) return null
|
if (index <= 0 || index > lineCount) return null
|
||||||
randomAccessFile.seek(startLineIndexes[index])
|
synchronized(randomAccessFile) {
|
||||||
return readLogLine()?.toString()
|
randomAccessFile.seek(startLineIndexes[index])
|
||||||
|
return readLogLine()?.toString()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLogLine(index: Int): LogLine? {
|
fun getLogLine(index: Int): LogLine? {
|
||||||
@ -109,7 +117,6 @@ class LogManager(
|
|||||||
private val remoteSideContext: RemoteSideContext
|
private val remoteSideContext: RemoteSideContext
|
||||||
): AbstractLogger(LogChannel.MANAGER) {
|
): AbstractLogger(LogChannel.MANAGER) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "SnapEnhanceManager"
|
|
||||||
private val LOG_LIFETIME = 24.hours
|
private val LOG_LIFETIME = 24.hours
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,13 +125,13 @@ class LogManager(
|
|||||||
var lineAddListener = { _: LogLine -> }
|
var lineAddListener = { _: LogLine -> }
|
||||||
|
|
||||||
private val logFolder = File(remoteSideContext.androidContext.cacheDir, "logs")
|
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 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 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() }})") }
|
private val filePathRegex by lazy { Regex("([a-zA-Z0-9_\\-./]+)\\.(${FileType.entries.joinToString("|") { file -> file.fileExtension.toString() }})") }
|
||||||
|
|
||||||
init {
|
fun init() {
|
||||||
if (!logFolder.exists()) {
|
if (!logFolder.exists()) {
|
||||||
logFolder.mkdirs()
|
logFolder.mkdirs()
|
||||||
}
|
}
|
||||||
@ -153,7 +160,9 @@ class LogManager(
|
|||||||
tag = tag,
|
tag = tag,
|
||||||
message = anonymizedMessage
|
message = anonymizedMessage
|
||||||
)
|
)
|
||||||
logFile.appendText("|$line\n", Charsets.UTF_8)
|
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
logFile?.appendText("|$line\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
lineAddListener(line)
|
lineAddListener(line)
|
||||||
Log.println(logLevel.priority, tag, anonymizedMessage)
|
Log.println(logLevel.priority, tag, anonymizedMessage)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
@ -172,8 +181,8 @@ class LogManager(
|
|||||||
val currentTime = System.currentTimeMillis()
|
val currentTime = System.currentTimeMillis()
|
||||||
logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
|
logFile = File(logFolder, "snapenhance_${getCurrentDateTime(pathSafe = true)}.log").also {
|
||||||
it.createNewFile()
|
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() {
|
fun clearLogs() {
|
||||||
@ -201,7 +210,7 @@ class LogManager(
|
|||||||
zipOutputStream.close()
|
zipOutputStream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile).also {
|
fun newReader(onAddLine: (LogLine) -> Unit) = LogReader(logFile!!).also {
|
||||||
lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) }
|
lineAddListener = { line -> it.incrementLineCount(); onAddLine(line) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,8 +7,10 @@ import me.rhunk.snapenhance.core.util.ktx.toParcelFileDescriptor
|
|||||||
class RemoteAccountStorage(
|
class RemoteAccountStorage(
|
||||||
private val context: RemoteSideContext
|
private val context: RemoteSideContext
|
||||||
): AccountStorage.Stub() {
|
): AccountStorage.Stub() {
|
||||||
private val accountFolder = context.androidContext.filesDir.resolve("accounts").also {
|
private val accountFolder by lazy {
|
||||||
if (!it.exists()) it.mkdirs()
|
context.androidContext.filesDir.resolve("accounts").also {
|
||||||
|
if (!it.exists()) it.mkdirs()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAccounts(): Map<String, String> {
|
override fun getAccounts(): Map<String, String> {
|
||||||
|
@ -19,6 +19,8 @@ import com.google.gson.Gson
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import me.rhunk.snapenhance.bridge.BridgeService
|
import me.rhunk.snapenhance.bridge.BridgeService
|
||||||
import me.rhunk.snapenhance.common.BuildConfig
|
import me.rhunk.snapenhance.common.BuildConfig
|
||||||
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
|
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.bridge.wrapper.MappingsWrapper
|
||||||
import me.rhunk.snapenhance.common.config.ModConfig
|
import me.rhunk.snapenhance.common.config.ModConfig
|
||||||
import me.rhunk.snapenhance.e2ee.E2EEImplementation
|
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.scripting.RemoteScriptManager
|
||||||
|
import me.rhunk.snapenhance.storage.AppDatabase
|
||||||
import me.rhunk.snapenhance.task.TaskManager
|
import me.rhunk.snapenhance.task.TaskManager
|
||||||
import me.rhunk.snapenhance.ui.manager.MainActivity
|
import me.rhunk.snapenhance.ui.manager.MainActivity
|
||||||
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
|
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
|
||||||
@ -63,7 +64,7 @@ class RemoteSideContext(
|
|||||||
val translation = LocaleWrapper()
|
val translation = LocaleWrapper()
|
||||||
val mappings = MappingsWrapper()
|
val mappings = MappingsWrapper()
|
||||||
val taskManager = TaskManager(this)
|
val taskManager = TaskManager(this)
|
||||||
val modDatabase = ModDatabase(this)
|
val database = AppDatabase(this)
|
||||||
val streaksReminder = StreaksReminder(this)
|
val streaksReminder = StreaksReminder(this)
|
||||||
val log = LogManager(this)
|
val log = LogManager(this)
|
||||||
val scriptManager = RemoteScriptManager(this)
|
val scriptManager = RemoteScriptManager(this)
|
||||||
@ -94,27 +95,32 @@ class RemoteSideContext(
|
|||||||
val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() }
|
val gson: Gson by lazy { GsonBuilder().setPrettyPrinting().create() }
|
||||||
|
|
||||||
fun reload() {
|
fun reload() {
|
||||||
log.verbose("Loading RemoteSideContext")
|
|
||||||
runCatching {
|
runCatching {
|
||||||
config.loadFromContext(androidContext)
|
runBlocking(Dispatchers.IO) {
|
||||||
translation.apply {
|
log.init()
|
||||||
userLocale = config.locale
|
log.verbose("Loading RemoteSideContext")
|
||||||
loadFromContext(androidContext)
|
config.loadFromContext(androidContext)
|
||||||
}
|
launch {
|
||||||
mappings.apply {
|
mappings.apply {
|
||||||
loadFromContext(androidContext)
|
loadFromContext(androidContext)
|
||||||
init(androidContext)
|
init(androidContext)
|
||||||
}
|
}
|
||||||
taskManager.init()
|
}
|
||||||
modDatabase.init()
|
translation.apply {
|
||||||
streaksReminder.init()
|
userLocale = config.locale
|
||||||
scriptManager.init()
|
loadFromContext(androidContext)
|
||||||
messageLogger.init()
|
}
|
||||||
tracker.init()
|
database.init()
|
||||||
config.root.messaging.messageLogger.takeIf {
|
streaksReminder.init()
|
||||||
it.globalState == true
|
scriptManager.init()
|
||||||
}?.getAutoPurgeTime()?.let {
|
launch {
|
||||||
messageLogger.purgeAll(it)
|
taskManager.init()
|
||||||
|
config.root.messaging.messageLogger.takeIf {
|
||||||
|
it.globalState == true
|
||||||
|
}?.getAutoPurgeTime()?.let {
|
||||||
|
messageLogger.purgeAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
log.error("Failed to load RemoteSideContext", it)
|
log.error("Failed to load RemoteSideContext", it)
|
||||||
|
@ -1,29 +1,29 @@
|
|||||||
package me.rhunk.snapenhance
|
package me.rhunk.snapenhance
|
||||||
|
|
||||||
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
|
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.TrackerEventsResult
|
||||||
import me.rhunk.snapenhance.common.data.TrackerRule
|
import me.rhunk.snapenhance.common.data.TrackerRule
|
||||||
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
|
import me.rhunk.snapenhance.common.data.TrackerRuleEvent
|
||||||
import me.rhunk.snapenhance.common.util.toSerialized
|
import me.rhunk.snapenhance.common.util.toSerialized
|
||||||
|
import me.rhunk.snapenhance.storage.getRuleTrackerScopes
|
||||||
|
import me.rhunk.snapenhance.storage.getTrackerEvents
|
||||||
|
|
||||||
|
|
||||||
class RemoteTracker(
|
class RemoteTracker(
|
||||||
private val context: RemoteSideContext
|
private val context: RemoteSideContext
|
||||||
): TrackerInterface.Stub() {
|
): TrackerInterface.Stub() {
|
||||||
fun init() {
|
fun init() {}
|
||||||
/*TrackerEventType.entries.forEach { eventType ->
|
|
||||||
val ruleId = context.modDatabase.addTrackerRule(TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, null, null)
|
|
||||||
context.modDatabase.addTrackerRuleEvent(ruleId, TrackerFlags.TRACK or TrackerFlags.LOG or TrackerFlags.NOTIFY, eventType.key)
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTrackedEvents(eventType: String): String? {
|
override fun getTrackedEvents(eventType: String): String? {
|
||||||
val events = mutableMapOf<TrackerRule, MutableList<TrackerRuleEvent>>()
|
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)
|
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.AlarmManager
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@ -10,11 +10,10 @@ import android.content.Intent
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import kotlinx.coroutines.launch
|
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.bridge.ForceStartActivity
|
||||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
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 me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
@ -56,8 +55,8 @@ class StreaksReminder(
|
|||||||
PendingIntent.FLAG_IMMUTABLE)
|
PendingIntent.FLAG_IMMUTABLE)
|
||||||
)
|
)
|
||||||
|
|
||||||
val notifyFriendList = remoteSideContext.modDatabase.getFriends()
|
val notifyFriendList = remoteSideContext.database.getFriends()
|
||||||
.associateBy { remoteSideContext.modDatabase.getFriendStreaks(it.userId) }
|
.associateBy { remoteSideContext.database.getFriendStreaks(it.userId) }
|
||||||
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
|
.filter { (streaks, _) -> streaks != null && streaks.notify && streaks.isAboutToExpire(remainingHours) }
|
||||||
|
|
||||||
val notificationManager = getNotificationManager(ctx)
|
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.common.util.toParcelable
|
||||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||||
import me.rhunk.snapenhance.download.FFMpegProcessor
|
import me.rhunk.snapenhance.download.FFMpegProcessor
|
||||||
|
import me.rhunk.snapenhance.storage.*
|
||||||
import me.rhunk.snapenhance.task.Task
|
import me.rhunk.snapenhance.task.Task
|
||||||
import me.rhunk.snapenhance.task.TaskType
|
import me.rhunk.snapenhance.task.TaskType
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -47,7 +48,7 @@ class BridgeService : Service() {
|
|||||||
|
|
||||||
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
|
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val modDatabase = remoteSideContext.modDatabase
|
val modDatabase = remoteSideContext.database
|
||||||
val syncedObject = when (scope) {
|
val syncedObject = when (scope) {
|
||||||
SocialScope.FRIEND -> {
|
SocialScope.FRIEND -> {
|
||||||
if (updateOnly && modDatabase.getFriendInfo(id) == null) return
|
if (updateOnly && modDatabase.getFriendInfo(id) == null) return
|
||||||
@ -194,24 +195,24 @@ class BridgeService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getRules(uuid: String): List<String> {
|
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> {
|
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) {
|
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) {
|
override fun sync(callback: SyncCallback) {
|
||||||
syncCallback = callback
|
syncCallback = callback
|
||||||
measureTimeMillis {
|
measureTimeMillis {
|
||||||
remoteSideContext.modDatabase.getFriends().map { it.userId } .forEach { friendId ->
|
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
|
||||||
triggerScopeSync(SocialScope.FRIEND, friendId, true)
|
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)
|
triggerScopeSync(SocialScope.GROUP, groupId, true)
|
||||||
}
|
}
|
||||||
}.also {
|
}.also {
|
||||||
@ -229,7 +230,7 @@ class BridgeService : Service() {
|
|||||||
friends: List<String>
|
friends: List<String>
|
||||||
) {
|
) {
|
||||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||||
remoteSideContext.modDatabase.receiveMessagingDataCallback(
|
remoteSideContext.database.receiveMessagingDataCallback(
|
||||||
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
|
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
|
||||||
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package me.rhunk.snapenhance.download
|
package me.rhunk.snapenhance.download
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
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>()
|
private val lastModifiedMap = mutableMapOf<Uri, Long>()
|
||||||
|
|
||||||
fun addFile(file: DocumentFile) {
|
fun addFile(file: DocumentFile) {
|
||||||
files.add(file)
|
synchronized(lastModifiedMap) {
|
||||||
lastModifiedMap[file.uri] = file.lastModified()
|
files.add(file)
|
||||||
|
lastModifiedMap[file.uri] = file.lastModified()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
while (true) {
|
while (true) {
|
||||||
files.forEach { file ->
|
synchronized(lastModifiedMap) {
|
||||||
val lastModified = lastModifiedMap[file.uri] ?: return@forEach
|
files.forEach { file ->
|
||||||
runCatching {
|
val lastModified = lastModifiedMap[file.uri] ?: return@forEach
|
||||||
val newLastModified = file.lastModified()
|
runCatching {
|
||||||
if (newLastModified > lastModified) {
|
val newLastModified = file.lastModified()
|
||||||
lastModifiedMap[file.uri] = newLastModified
|
if (newLastModified > lastModified) {
|
||||||
onReload(file)
|
lastModifiedMap[file.uri] = newLastModified
|
||||||
|
onReload(file)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTrace()
|
||||||
}
|
}
|
||||||
}.onFailure {
|
|
||||||
it.printStackTrace()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delay(1000)
|
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.IPCListeners
|
||||||
import me.rhunk.snapenhance.scripting.impl.ManagerIPC
|
import me.rhunk.snapenhance.scripting.impl.ManagerIPC
|
||||||
import me.rhunk.snapenhance.scripting.impl.ManagerScriptConfig
|
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.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
@ -28,6 +32,10 @@ class RemoteScriptManager(
|
|||||||
scripting = this@RemoteScriptManager
|
scripting = this@RemoteScriptManager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val okHttpClient by lazy {
|
||||||
|
OkHttpClient.Builder().build()
|
||||||
|
}
|
||||||
|
|
||||||
private var autoReloadListener: AutoReloadListener? = null
|
private var autoReloadListener: AutoReloadListener? = null
|
||||||
private val autoReloadHandler by lazy {
|
private val autoReloadHandler by lazy {
|
||||||
AutoReloadHandler(context.coroutineScope) {
|
AutoReloadHandler(context.coroutineScope) {
|
||||||
@ -49,6 +57,7 @@ class RemoteScriptManager(
|
|||||||
private val ipcListeners = IPCListeners()
|
private val ipcListeners = IPCListeners()
|
||||||
|
|
||||||
fun sync() {
|
fun sync() {
|
||||||
|
cachedModuleInfo.clear()
|
||||||
getScriptFileNames().forEach { name ->
|
getScriptFileNames().forEach { name ->
|
||||||
runCatching {
|
runCatching {
|
||||||
getScriptInputStream(name) { stream ->
|
getScriptInputStream(name) { stream ->
|
||||||
@ -63,7 +72,7 @@ class RemoteScriptManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.modDatabase.syncScripts(cachedModuleInfo.values.toList())
|
context.database.syncScripts(cachedModuleInfo.values.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
@ -77,7 +86,11 @@ class RemoteScriptManager(
|
|||||||
|
|
||||||
sync()
|
sync()
|
||||||
enabledScripts.forEach { name ->
|
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) {
|
fun loadScript(path: String) {
|
||||||
val content = getScriptContent(path) ?: return
|
val content = getScriptContent(path) ?: return
|
||||||
|
runtime.load(path, content)
|
||||||
if (context.config.root.scripting.autoReload.getNullable() != null) {
|
if (context.config.root.scripting.autoReload.getNullable() != null) {
|
||||||
autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
|
autoReloadHandler.addFile(getScriptsFolder()?.findFile(path) ?: return)
|
||||||
}
|
}
|
||||||
runtime.load(path, content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unloadScript(scriptPath: String) {
|
fun unloadScript(scriptPath: String) {
|
||||||
@ -119,10 +132,38 @@ class RemoteScriptManager(
|
|||||||
return (getScriptsFolder() ?: return emptyList()).listFiles().filter { it.name?.endsWith(".js") ?: false }.map { it.name!! }
|
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> {
|
override fun getEnabledScripts(): List<String> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
getScriptFileNames().filter {
|
getScriptFileNames().filter {
|
||||||
context.modDatabase.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
|
context.database.isScriptEnabled(cachedModuleInfo[it]?.name ?: return@filter false)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.log.error("Failed to get enabled scripts", it)
|
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)
|
val currentRoute = routes.getCurrentRoute(navBackStackEntry)
|
||||||
primaryRoutes.forEach { route ->
|
primaryRoutes.forEach { route ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
alwaysShowLabel = false,
|
alwaysShowLabel = true,
|
||||||
icon = {
|
icon = {
|
||||||
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
|
Icon(imageVector = route.routeInfo.icon, contentDescription = null)
|
||||||
},
|
},
|
||||||
@ -88,7 +88,7 @@ class Navigation(
|
|||||||
softWrap = false,
|
softWrap = false,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.wrapContentWidth(unbounded = true),
|
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,
|
selected = currentRoute == route,
|
||||||
|
@ -15,7 +15,6 @@ import androidx.navigation.NavDestination.Companion.hierarchy
|
|||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation.NavGraphBuilder
|
||||||
import me.rhunk.snapenhance.RemoteSideContext
|
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.LoggerHistoryRoot
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
|
import me.rhunk.snapenhance.ui.manager.pages.TasksRoot
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot
|
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.ManageScope
|
||||||
import me.rhunk.snapenhance.ui.manager.pages.social.MessagingPreview
|
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.social.SocialRoot
|
||||||
|
import me.rhunk.snapenhance.ui.manager.pages.tracker.EditRule
|
||||||
|
import me.rhunk.snapenhance.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||||
|
|
||||||
|
|
||||||
data class RouteInfo(
|
data class RouteInfo(
|
||||||
@ -55,6 +56,7 @@ class Routes(
|
|||||||
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
|
val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home)
|
||||||
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
|
val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home)
|
||||||
val friendTracker = route(RouteInfo("friend_tracker"), FriendTrackerManagerRoot()).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 social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot())
|
||||||
val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social)
|
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.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.DownloadRequest
|
||||||
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
||||||
import me.rhunk.snapenhance.common.data.download.createNewFilePath
|
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.copyToClipboard
|
||||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
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.DecodedAttachment
|
||||||
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
|
import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder
|
||||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
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 me.rhunk.snapenhance.ui.manager.Routes
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
import java.text.DateFormat
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
@ -114,7 +121,7 @@ class LoggerHistoryRoot : Routes.Route() {
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(4.dp)
|
.padding(8.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@ -123,7 +130,7 @@ class LoggerHistoryRoot : Routes.Route() {
|
|||||||
LaunchedEffect(Unit, message) {
|
LaunchedEffect(Unit, message) {
|
||||||
runCatching {
|
runCatching {
|
||||||
decodeMessage(message) { senderId, contentType, messageReader, attachments ->
|
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
|
@Composable
|
||||||
fun ContentHeader() {
|
fun ContentHeader() {
|
||||||
@ -141,6 +148,26 @@ class LoggerHistoryRoot : Routes.Route() {
|
|||||||
context.androidContext.copyToClipboard(content)
|
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()
|
ContentHeader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,9 +236,9 @@ class LoggerHistoryRoot : Routes.Route() {
|
|||||||
) {
|
) {
|
||||||
fun formatConversationId(conversationId: String?): String? {
|
fun formatConversationId(conversationId: String?): String? {
|
||||||
if (conversationId == null) return null
|
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)
|
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))
|
translation.format("list_friend_format", "name" to (it.displayName?.let { name -> "$name (${it.mutableUsername})" } ?: it.mutableUsername))
|
||||||
} ?: conversationId
|
} ?: conversationId
|
||||||
}
|
}
|
||||||
@ -225,13 +252,8 @@ class LoggerHistoryRoot : Routes.Route() {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
val conversations = remember { mutableStateListOf<String>() }
|
val conversations by rememberAsyncMutableState(defaultValue = emptyList()) {
|
||||||
|
loggerWrapper.getAllConversations().toMutableList()
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
conversations.clear()
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
conversations.addAll(loggerWrapper.getAllConversations())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
@ -16,7 +16,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
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.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.lifecycle.Lifecycle
|
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.DownloadMetadata
|
||||||
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
||||||
import me.rhunk.snapenhance.common.data.download.createNewFilePath
|
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.common.util.ktx.longHashCode
|
||||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||||
import me.rhunk.snapenhance.download.FFMpegProcessor
|
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) }
|
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) {
|
if (taskSelection.size == 1) {
|
||||||
taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.let { documentFile ->
|
val selectionExists by rememberAsyncMutableState(defaultValue = false) {
|
||||||
IconButton(onClick = {
|
taskSelection.firstOrNull()?.second?.exists() == true
|
||||||
runCatching {
|
}
|
||||||
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
if (selectionExists) {
|
||||||
setDataAndType(documentFile.uri, documentFile.type)
|
taskSelection.firstOrNull()?.second?.let { documentFile ->
|
||||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
IconButton(onClick = {
|
||||||
})
|
runCatching {
|
||||||
taskSelection.clear()
|
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||||
}.onFailure {
|
setDataAndType(documentFile.uri, documentFile.type)
|
||||||
context.log.error("Failed to open file ${taskSelection.first().second}", it)
|
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 }) {
|
if (taskSelection.size > 1) {
|
||||||
IconButton(onClick = {
|
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||||
mergeSelection(taskSelection.toList().also {
|
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||||
taskSelection.clear()
|
}
|
||||||
}.map { it.first to it.second!! })
|
|
||||||
}) {
|
if (canMergeSelection) {
|
||||||
Icon(Icons.Filled.Merge, contentDescription = "Merge")
|
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()) {
|
if (taskSelection.isNotEmpty()) {
|
||||||
Text(translation["remove_selected_tasks_title"])
|
Text(translation["remove_selected_tasks_title"])
|
||||||
Row (
|
Row (
|
||||||
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable {
|
modifier = Modifier
|
||||||
alsoDeleteFiles = !alsoDeleteFiles
|
.padding(top = 10.dp)
|
||||||
},
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
alsoDeleteFiles = !alsoDeleteFiles
|
||||||
|
},
|
||||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@ -207,19 +223,22 @@ class TasksRoot : Routes.Route() {
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
showConfirmDialog = false
|
showConfirmDialog = false
|
||||||
|
|
||||||
if (taskSelection.isNotEmpty()) {
|
if (taskSelection.isNotEmpty()) {
|
||||||
taskSelection.forEach { (task, documentFile) ->
|
taskSelection.forEach { (task, documentFile) ->
|
||||||
context.taskManager.removeTask(task)
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
recentTasks.remove(task)
|
context.taskManager.removeTask(task)
|
||||||
if (alsoDeleteFiles) {
|
if (alsoDeleteFiles) {
|
||||||
documentFile?.delete()
|
documentFile?.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
recentTasks.remove(task)
|
||||||
}
|
}
|
||||||
activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
|
activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) }
|
||||||
taskSelection.clear()
|
taskSelection.clear()
|
||||||
} else {
|
} else {
|
||||||
context.taskManager.clearAllTasks()
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
context.taskManager.clearAllTasks()
|
||||||
|
}
|
||||||
recentTasks.clear()
|
recentTasks.clear()
|
||||||
activeTasks.forEach {
|
activeTasks.forEach {
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -255,16 +274,17 @@ class TasksRoot : Routes.Route() {
|
|||||||
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
|
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
|
||||||
var taskProgress by remember { mutableIntStateOf(-1) }
|
var taskProgress by remember { mutableIntStateOf(-1) }
|
||||||
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
|
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) {
|
var documentFileMimeType by remember { mutableStateOf("") }
|
||||||
launch(Dispatchers.IO) {
|
var isDocumentFileReadable by remember { mutableStateOf(true) }
|
||||||
documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch)
|
val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
|
||||||
isDocumentFileReadable = documentFile?.canRead() ?: false
|
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
|
||||||
|
documentFileMimeType = type ?: ""
|
||||||
|
isDocumentFileReadable = canRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val listener = remember { PendingTaskListener(
|
val listener = remember { PendingTaskListener(
|
||||||
onStateChange = {
|
onStateChange = {
|
||||||
taskStatus = it
|
taskStatus = it
|
||||||
@ -285,19 +305,21 @@ class TasksRoot : Routes.Route() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedCard(modifier = modifier.clickable {
|
OutlinedCard(modifier = modifier
|
||||||
if (isSelected) {
|
.clickable {
|
||||||
taskSelection.removeIf { it.first == task }
|
if (isSelected) {
|
||||||
return@clickable
|
taskSelection.removeIf { it.first == task }
|
||||||
|
return@clickable
|
||||||
|
}
|
||||||
|
taskSelection.add(task to documentFile)
|
||||||
}
|
}
|
||||||
taskSelection.add(task to documentFile)
|
.let {
|
||||||
}.let {
|
if (isSelected) {
|
||||||
if (isSelected) {
|
it
|
||||||
it
|
.border(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
.border(2.dp, MaterialTheme.colorScheme.primary)
|
.clip(MaterialTheme.shapes.medium)
|
||||||
.clip(MaterialTheme.shapes.medium)
|
} else it
|
||||||
} else it
|
}) {
|
||||||
}) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(15.dp),
|
modifier = Modifier.padding(15.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@ -305,13 +327,12 @@ class TasksRoot : Routes.Route() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(end = 15.dp)
|
modifier = Modifier.padding(end = 15.dp)
|
||||||
) {
|
) {
|
||||||
documentFile?.let { file ->
|
documentFile?.let {
|
||||||
val mimeType = file.type ?: ""
|
|
||||||
when {
|
when {
|
||||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
|
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
|
||||||
mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
|
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
|
||||||
mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
|
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
|
||||||
mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
|
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
|
||||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
|
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: 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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.*
|
import me.rhunk.snapenhance.ui.util.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
class FeaturesRoot : Routes.Route() {
|
class FeaturesRoot : Routes.Route() {
|
||||||
private val alertDialogs by lazy { AlertDialogs(context.translation) }
|
private val alertDialogs by lazy { AlertDialogs(context.translation) }
|
||||||
|
|
||||||
@ -313,7 +311,7 @@ class FeaturesRoot : Routes.Route() {
|
|||||||
FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
|
FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722),
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
|
.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),
|
.padding(all = 4.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
property.key.params.icon?.let { iconName ->
|
property.key.params.icon?.let { icon ->
|
||||||
//TODO: find a better way to load icons
|
Icon(
|
||||||
val icon: ImageVector? = remember(iconName) {
|
imageVector = icon,
|
||||||
runCatching {
|
contentDescription = null,
|
||||||
val cl = Class.forName("androidx.compose.material.icons.filled.${iconName}Kt")
|
modifier = Modifier
|
||||||
val method = cl.declaredMethods.first()
|
.align(Alignment.CenterVertically)
|
||||||
method.invoke(null, Icons.Filled) as ImageVector
|
.padding(start = 10.dp)
|
||||||
}.getOrNull()
|
)
|
||||||
}
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterVertically)
|
|
||||||
.padding(start = 10.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
@ -31,6 +31,7 @@ import androidx.navigation.NavBackStackEntry
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.rhunk.snapenhance.LogReader
|
import me.rhunk.snapenhance.LogReader
|
||||||
import me.rhunk.snapenhance.common.logger.LogChannel
|
import me.rhunk.snapenhance.common.logger.LogChannel
|
||||||
@ -64,8 +65,12 @@ class HomeLogs : Routes.Route() {
|
|||||||
modifier = Modifier.align(Alignment.CenterVertically)
|
modifier = Modifier.align(Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(onClick = {
|
DropdownMenuItem(onClick = {
|
||||||
context.log.clearLogs()
|
context.coroutineScope.launch {
|
||||||
navigate()
|
context.log.clearLogs()
|
||||||
|
}
|
||||||
|
routes.navController.navigate(routeInfo.id) {
|
||||||
|
popUpTo(routeInfo.id) { inclusive = true }
|
||||||
|
}
|
||||||
showDropDown = false
|
showDropDown = false
|
||||||
}, text = {
|
}, text = {
|
||||||
Text(translation["clear_logs_button"])
|
Text(translation["clear_logs_button"])
|
||||||
@ -148,63 +153,73 @@ class HomeLogs : Routes.Route() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(lineCount) { index ->
|
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) }
|
var expand by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(modifier = Modifier
|
logLine?.let { line ->
|
||||||
.fillMaxWidth()
|
Box(modifier = Modifier
|
||||||
.pointerInput(Unit) {
|
.fillMaxWidth()
|
||||||
detectTapGestures(
|
.pointerInput(Unit) {
|
||||||
onLongPress = {
|
detectTapGestures(
|
||||||
coroutineScope.launch {
|
onLongPress = {
|
||||||
clipboardManager.setText(AnnotatedString(logLine.message))
|
coroutineScope.launch {
|
||||||
}
|
clipboardManager.setText(
|
||||||
},
|
AnnotatedString(
|
||||||
onTap = {
|
line.message
|
||||||
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
|
|
||||||
},
|
},
|
||||||
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(
|
||||||
text = LogChannel.fromChannel(logLine.tag)?.shortName ?: logLine.tag,
|
text = line.message.trimIndent(),
|
||||||
modifier = Modifier.padding(start = 4.dp),
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
)
|
maxLines = if (expand) Int.MAX_VALUE else 6,
|
||||||
|
overflow = if (expand) TextOverflow.Visible else TextOverflow.Ellipsis,
|
||||||
Text(
|
softWrap = !expand,
|
||||||
text = logLine.dateTime,
|
|
||||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.ScrollState
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.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.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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.res.vectorResource
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.rhunk.snapenhance.R
|
import me.rhunk.snapenhance.R
|
||||||
import me.rhunk.snapenhance.common.BuildConfig
|
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.Routes
|
||||||
import me.rhunk.snapenhance.ui.manager.data.InstallationSummary
|
|
||||||
import me.rhunk.snapenhance.ui.manager.data.Updater
|
import me.rhunk.snapenhance.ui.manager.data.Updater
|
||||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||||
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
|
|
||||||
|
|
||||||
class HomeRoot : Routes.Route() {
|
class HomeRoot : Routes.Route() {
|
||||||
companion object {
|
companion object {
|
||||||
@ -46,61 +51,33 @@ class HomeRoot : Routes.Route() {
|
|||||||
|
|
||||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||||
|
|
||||||
override val init: () -> Unit = {
|
private fun launchActionIntent(action: EnumAction) {
|
||||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
val intent = context.androidContext.packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
|
||||||
|
intent?.putExtra(EnumAction.ACTION_PARAMETER, action.key)
|
||||||
|
context.androidContext.startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private val cards by lazy {
|
||||||
private fun SummaryCards(installationSummary: InstallationSummary) {
|
mapOf(
|
||||||
val summaryInfo = remember {
|
("Friend Tracker" to Icons.Default.PersonSearch) to {
|
||||||
mapOf(
|
routes.friendTracker.navigateReset()
|
||||||
"Build Issuer" to (installationSummary.modInfo?.buildIssuer ?: "Unknown"),
|
},
|
||||||
"Build Type" to (if (installationSummary.modInfo?.isDebugBuild == true) "debug" else "release"),
|
("Logger History" to Icons.Default.History) to {
|
||||||
"Build Version" to (installationSummary.modInfo?.buildVersion ?: "Unknown"),
|
routes.loggerHistory.navigateReset()
|
||||||
"Build Package" to (installationSummary.modInfo?.buildPackageName ?: "Unknown"),
|
},
|
||||||
"Activity Package" to (installationSummary.modInfo?.loaderPackageName ?: "Unknown"),
|
).toMutableMap().apply {
|
||||||
"Device" to installationSummary.platformInfo.device,
|
EnumAction.entries.forEach { action ->
|
||||||
"Android Version" to installationSummary.platformInfo.androidVersion,
|
this[context.translation["actions.${action.key}.name"] to action.icon] = {
|
||||||
"System ABI" to installationSummary.platformInfo.systemAbi
|
launchActionIntent(action)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val init: () -> Unit = {
|
||||||
|
activityLauncherHelper = ActivityLauncherHelper(context.activity !!)
|
||||||
|
}
|
||||||
|
|
||||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
override val topBarActions: @Composable (RowScope.() -> Unit) = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
routes.homeLogs.navigate()
|
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 = {
|
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||||
val avenirNextFontFamily = remember {
|
val avenirNextFontFamily = remember {
|
||||||
FontFamily(
|
FontFamily(
|
||||||
@ -121,26 +128,17 @@ class HomeRoot : Routes.Route() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestUpdate by remember { mutableStateOf<Updater.LatestRelease?>(null) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.verticalScroll(ScrollState(0))
|
.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(
|
||||||
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,
|
fontSize = 30.sp,
|
||||||
fontFamily = avenirNextFontFamily,
|
fontFamily = avenirNextFontFamily,
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||||
@ -153,50 +151,48 @@ class HomeRoot : Routes.Route() {
|
|||||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
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(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterHorizontally),
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
modifier = Modifier
|
15.dp, Alignment.CenterHorizontally
|
||||||
|
), modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(all = 10.dp)
|
.padding(all = 10.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
LinkIcon(
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
|
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
|
||||||
contentDescription = null,
|
dataArray = intArrayOf(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
101, 99, 110, 97, 104, 110, 69, 112, 97, 110, 83, 47, 107, 110,
|
||||||
modifier = Modifier.size(32.dp).clickable {
|
117, 104, 114, 47, 109, 111, 99, 46, 98, 117, 104, 116, 105,
|
||||||
context.activity?.startActivity(
|
103, 47, 58, 115, 112, 116, 116, 104
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
Icon(
|
|
||||||
|
LinkIcon(
|
||||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
|
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
|
||||||
contentDescription = null,
|
dataArray = intArrayOf(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
101, 99, 110, 97, 104, 110, 101, 112, 97, 110, 115, 47, 101,
|
||||||
modifier = Modifier.size(32.dp).clickable {
|
109, 46, 116, 47, 47, 58, 115, 112, 116, 116, 104
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) {
|
if (latestUpdate != null) {
|
||||||
@ -209,7 +205,7 @@ class HomeRoot : Routes.Route() {
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
){
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -223,17 +219,16 @@ class HomeRoot : Routes.Route() {
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp, text = translation.format(
|
||||||
text = translation.format("update_content", "version" to (latestUpdate?.versionName ?: "unknown")),
|
"update_content",
|
||||||
lineHeight = 20.sp
|
"version" to (latestUpdate?.versionName ?: "unknown")
|
||||||
|
), lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
context.activity?.startActivity(
|
context.activity?.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
data = Uri.parse(latestUpdate?.releaseUrl)
|
||||||
data = Uri.parse(latestUpdate?.releaseUrl)
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
}, modifier = Modifier.height(40.dp)) {
|
}, modifier = Modifier.height(40.dp)) {
|
||||||
Text(text = translation["update_button"])
|
Text(text = translation["update_button"])
|
||||||
}
|
}
|
||||||
@ -241,38 +236,93 @@ class HomeRoot : Routes.Route() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
var showQuickActionsMenu by remember { mutableStateOf(false) }
|
||||||
var installationSummary by remember { mutableStateOf(null as InstallationSummary?) }
|
|
||||||
|
|
||||||
fun updateInstallationSummary(scope: CoroutineScope) {
|
Row(
|
||||||
scope.launch(Dispatchers.IO) {
|
modifier = Modifier
|
||||||
runCatching {
|
.fillMaxWidth()
|
||||||
installationSummary = context.installationSummary
|
.padding(start = 20.dp, end = 30.dp, top = 20.dp),
|
||||||
}.onFailure {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
context.longToast("SnapEnhance failed to load installation summary: ${it.message}")
|
) {
|
||||||
|
Text("Quick Actions", fontSize = 20.sp, modifier = Modifier.weight(1f))
|
||||||
|
Box {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showQuickActionsMenu = !showQuickActionsMenu },
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.MoreVert, contentDescription = null)
|
||||||
}
|
}
|
||||||
runCatching {
|
DropdownMenu(
|
||||||
if (!BuildConfig.DEBUG) {
|
expanded = showQuickActionsMenu,
|
||||||
latestUpdate = Updater.checkForLatestRelease()
|
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.core.net.toUri
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.rhunk.snapenhance.common.Constants
|
import me.rhunk.snapenhance.common.Constants
|
||||||
import me.rhunk.snapenhance.common.action.EnumAction
|
import me.rhunk.snapenhance.common.action.EnumAction
|
||||||
import me.rhunk.snapenhance.common.bridge.types.BridgeFileType
|
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.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.setup.Requirements
|
import me.rhunk.snapenhance.ui.setup.Requirements
|
||||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
||||||
@ -165,13 +167,11 @@ class HomeSettings : Routes.Route() {
|
|||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
var storedMessagesCount by remember { mutableIntStateOf(0) }
|
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||||
var storedStoriesCount by remember { mutableIntStateOf(0) }
|
context.messageLogger.getStoredMessageCount()
|
||||||
LaunchedEffect(Unit) {
|
}
|
||||||
withContext(Dispatchers.IO) {
|
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
context.messageLogger.getStoredStoriesCount()
|
||||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@ -273,7 +273,9 @@ class HomeSettings : Routes.Route() {
|
|||||||
}
|
}
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
runCatching {
|
runCatching {
|
||||||
selectedFileType.resolve(context.androidContext).delete()
|
context.coroutineScope.launch {
|
||||||
|
selectedFileType.resolve(context.androidContext).delete()
|
||||||
|
}
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.log.error("Failed to clear file", it)
|
context.log.error("Failed to clear file", it)
|
||||||
context.longToast("Failed to clear file! ${it.localizedMessage}")
|
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.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Link
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
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.EnumScriptInterface
|
||||||
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
import me.rhunk.snapenhance.common.scripting.ui.InterfaceManager
|
||||||
import me.rhunk.snapenhance.common.scripting.ui.ScriptInterface
|
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.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.ActivityLauncherHelper
|
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.chooseFolder
|
||||||
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
|
import me.rhunk.snapenhance.ui.util.pullrefresh.PullRefreshIndicator
|
||||||
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
|
import me.rhunk.snapenhance.ui.util.pullrefresh.pullRefresh
|
||||||
@ -35,19 +45,213 @@ import me.rhunk.snapenhance.ui.util.pullrefresh.rememberPullRefreshState
|
|||||||
|
|
||||||
class ScriptingRoot : Routes.Route() {
|
class ScriptingRoot : Routes.Route() {
|
||||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||||
|
private val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||||
|
|
||||||
override val init: () -> Unit = {
|
override val init: () -> Unit = {
|
||||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
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
|
@Composable
|
||||||
fun ModuleItem(script: ModuleInfo) {
|
fun ModuleItem(script: ModuleInfo) {
|
||||||
var enabled by remember {
|
var enabled by rememberAsyncMutableState(defaultValue = false) {
|
||||||
mutableStateOf(context.modDatabase.isScriptEnabled(script.name))
|
context.database.isScriptEnabled(script.name)
|
||||||
}
|
}
|
||||||
var openSettings by remember {
|
var openSettings by remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
var openActions by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -59,43 +263,64 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
|
if (!enabled) return@clickable
|
||||||
openSettings = !openSettings
|
openSettings = !openSettings
|
||||||
}
|
}
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 8.dp)
|
.padding(end = 8.dp)
|
||||||
) {
|
) {
|
||||||
Text(text = script.displayName ?: script.name, fontSize = 20.sp,)
|
Text(text = script.displayName ?: script.name, fontSize = 20.sp)
|
||||||
Text(text = script.description ?: "No description", fontSize = 14.sp,)
|
Text(text = script.description ?: "No description", fontSize = 14.sp)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { openSettings = !openSettings }) {
|
IconButton(onClick = {
|
||||||
Icon(imageVector = Icons.Default.Settings, contentDescription = "Settings",)
|
openActions = !openActions
|
||||||
|
}) {
|
||||||
|
Icon(imageVector = Icons.Default.Build, contentDescription = "Actions")
|
||||||
}
|
}
|
||||||
Switch(
|
Switch(
|
||||||
checked = enabled,
|
checked = enabled,
|
||||||
onCheckedChange = { isChecked ->
|
onCheckedChange = { isChecked ->
|
||||||
context.modDatabase.setScriptEnabled(script.name, isChecked)
|
openSettings = false
|
||||||
enabled = isChecked
|
context.coroutineScope.launch(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val modulePath = context.scriptManager.getModulePath(script.name)!!
|
val modulePath = context.scriptManager.getModulePath(script.name)!!
|
||||||
context.scriptManager.unloadScript(modulePath)
|
context.scriptManager.unloadScript(modulePath)
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
context.scriptManager.loadScript(modulePath)
|
context.scriptManager.loadScript(modulePath)
|
||||||
context.scriptManager.runtime.getModuleByName(script.name)
|
context.scriptManager.runtime.getModuleByName(script.name)
|
||||||
?.callFunction("module.onSnapEnhanceLoad")
|
?.callFunction("module.onSnapEnhanceLoad")
|
||||||
context.shortToast("Loaded script ${script.name}")
|
context.shortToast("Loaded script ${script.name}")
|
||||||
} else {
|
} else {
|
||||||
context.shortToast("Unloaded script ${script.name}")
|
context.shortToast("Unloaded script ${script.name}")
|
||||||
}
|
}
|
||||||
}.onFailure { throwable ->
|
|
||||||
enabled = !isChecked
|
context.database.setScriptEnabled(script.name, isChecked)
|
||||||
("Failed to ${if (isChecked) "enable" else "disable"} script").let {
|
withContext(Dispatchers.Main) {
|
||||||
context.log.error(it, throwable)
|
enabled = isChecked
|
||||||
context.shortToast(it)
|
}
|
||||||
|
}.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)
|
ScriptSettings(script)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (openActions) {
|
||||||
|
ModuleActions(script) { openActions = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val floatingActionButton: @Composable () -> Unit = {
|
override val floatingActionButton: @Composable () -> Unit = {
|
||||||
|
var showImportDialog by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showImportDialog) {
|
||||||
|
ImportRemoteScript {
|
||||||
|
showImportDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
showImportDialog = true
|
||||||
},
|
},
|
||||||
icon= { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
|
icon = { Icon(imageVector = Icons.Default.Link, contentDescription = "Link") },
|
||||||
text = {
|
text = {
|
||||||
Text(text = "Import from URL")
|
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(text = "Open Scripts Folder")
|
Text(text = "Open Scripts Folder")
|
||||||
},
|
},
|
||||||
@ -144,8 +387,9 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScriptSettings(script: ModuleInfo) {
|
fun ScriptSettings(script: ModuleInfo) {
|
||||||
val settingsInterface = remember {
|
val settingsInterface = remember {
|
||||||
val module = context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
val module =
|
||||||
|
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
||||||
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
|
(module.getBinding(InterfaceManager::class))?.buildInterface(EnumScriptInterface.SETTINGS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,43 +399,44 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(8.dp)
|
modifier = Modifier.padding(8.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ScriptInterface(interfaceBuilder = settingsInterface)
|
ScriptInterface(interfaceBuilder = settingsInterface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||||
var scriptModules by remember { mutableStateOf(listOf<ModuleInfo>()) }
|
val scriptingFolder by rememberAsyncMutableState(
|
||||||
var scriptingFolder by remember { mutableStateOf(null as DocumentFile?) }
|
defaultValue = null,
|
||||||
|
updateDispatcher = reloadDispatcher
|
||||||
|
) {
|
||||||
|
context.scriptManager.getScriptsFolder()
|
||||||
|
}
|
||||||
|
val scriptModules by rememberAsyncMutableState(
|
||||||
|
defaultValue = emptyList(),
|
||||||
|
updateDispatcher = reloadDispatcher
|
||||||
|
) {
|
||||||
|
context.scriptManager.sync()
|
||||||
|
context.database.getScripts()
|
||||||
|
}
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var refreshing by remember {
|
var refreshing by remember {
|
||||||
mutableStateOf(false)
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
refreshing = true
|
refreshing = true
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
syncScripts()
|
reloadDispatcher.dispatch()
|
||||||
refreshing = false
|
refreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
|
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
|
||||||
refreshing = true
|
refreshing = true
|
||||||
syncScripts()
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
coroutineScope.launch {
|
reloadDispatcher.dispatch()
|
||||||
delay(300)
|
|
||||||
refreshing = false
|
refreshing = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -206,7 +451,7 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
if (scriptingFolder == null) {
|
if (scriptingFolder == null && !refreshing) {
|
||||||
Text(
|
Text(
|
||||||
text = "No scripts folder selected",
|
text = "No scripts folder selected",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@ -218,7 +463,7 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
context.config.root.scripting.moduleFolder.set(it)
|
context.config.root.scripting.moduleFolder.set(it)
|
||||||
context.config.writeConfig()
|
context.config.writeConfig()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
syncScripts()
|
reloadDispatcher.dispatch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@ -295,7 +540,10 @@ class ScriptingRoot : Routes.Route() {
|
|||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
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.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.ReceiversConfig
|
||||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
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.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||||
|
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||||
|
|
||||||
class AddFriendDialog(
|
class AddFriendDialog(
|
||||||
private val context: RemoteSideContext,
|
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")}
|
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ListCardEntry(name: String, getCurrentState: () -> Boolean, onState: (Boolean) -> Unit = {}) {
|
private fun ListCardEntry(
|
||||||
var currentState by remember { mutableStateOf(getCurrentState()) }
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
currentState = !currentState
|
currentState = !currentState
|
||||||
onState(currentState)
|
stateCache[id] = currentState
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
onState(currentState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(4.dp),
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
BitmojiImage(
|
||||||
|
context = this@AddFriendDialog.context,
|
||||||
|
url = bitmoji,
|
||||||
|
modifier = Modifier.padding(end = 2.dp),
|
||||||
|
size = 32,
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.onGloballyPositioned {
|
|
||||||
currentState = getCurrentState()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = currentState,
|
checked = currentState,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
currentState = it
|
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) }
|
var hasFetchError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
context.modDatabase.receiveMessagingDataCallback = { friends, groups ->
|
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||||
cachedFriends = friends
|
cachedFriends = friends
|
||||||
cachedGroups = groups
|
cachedGroups = groups
|
||||||
timeoutJob?.cancel()
|
timeoutJob?.cancel()
|
||||||
@ -138,7 +166,7 @@ class AddFriendDialog(
|
|||||||
}
|
}
|
||||||
timeoutJob = coroutineScope.launch {
|
timeoutJob = coroutineScope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
delay(10000)
|
delay(20000)
|
||||||
hasFetchError = true
|
hasFetchError = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -216,15 +244,11 @@ class AddFriendDialog(
|
|||||||
items(filteredGroups.size) {
|
items(filteredGroups.size) {
|
||||||
val group = filteredGroups[it]
|
val group = filteredGroups[it]
|
||||||
ListCardEntry(
|
ListCardEntry(
|
||||||
|
id = group.conversationId,
|
||||||
name = group.name,
|
name = group.name,
|
||||||
getCurrentState = { context.modDatabase.getGroupInfo(group.conversationId) != null }
|
getCurrentState = { actionHandler.getGroupState(group) }
|
||||||
) { state ->
|
) { state ->
|
||||||
if (state) {
|
actionHandler.onGroupState(group, state)
|
||||||
context.bridgeService?.triggerScopeSync(SocialScope.GROUP, group.conversationId)
|
|
||||||
} else {
|
|
||||||
context.modDatabase.deleteGroup(group.conversationId)
|
|
||||||
}
|
|
||||||
socialRoot.updateScopeLists()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,19 +261,18 @@ class AddFriendDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(filteredFriends.size) {
|
items(filteredFriends.size) { index ->
|
||||||
val friend = filteredFriends[it]
|
val friend = filteredFriends[index]
|
||||||
|
|
||||||
ListCardEntry(
|
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,
|
name = friend.displayName?.takeIf { name -> name.isNotBlank() } ?: friend.mutableUsername,
|
||||||
getCurrentState = { context.modDatabase.getFriendInfo(friend.userId) != null }
|
getCurrentState = { actionHandler.getFriendState(friend) }
|
||||||
) { state ->
|
) { state ->
|
||||||
if (state) {
|
actionHandler.onFriendState(friend, state)
|
||||||
context.bridgeService?.triggerScopeSync(SocialScope.FRIEND, friend.userId)
|
|
||||||
} else {
|
|
||||||
context.modDatabase.deleteFriend(friend.userId)
|
|
||||||
}
|
|
||||||
socialRoot.updateScopeLists()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,10 +29,10 @@ import me.rhunk.snapenhance.common.data.StoryData
|
|||||||
import me.rhunk.snapenhance.common.data.download.*
|
import me.rhunk.snapenhance.common.data.download.*
|
||||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||||
import me.rhunk.snapenhance.download.DownloadProcessor
|
import me.rhunk.snapenhance.download.DownloadProcessor
|
||||||
|
import me.rhunk.snapenhance.storage.getFriendInfo
|
||||||
import me.rhunk.snapenhance.ui.manager.Routes
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.Dialog
|
import me.rhunk.snapenhance.ui.util.Dialog
|
||||||
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
import me.rhunk.snapenhance.ui.util.coil.ImageRequestHelper
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -45,7 +45,7 @@ class LoggedStories : Routes.Route() {
|
|||||||
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
||||||
|
|
||||||
val stories = remember { mutableStateListOf<StoryData>() }
|
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 lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||||
|
|
||||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||||
|
@ -17,13 +17,19 @@ import androidx.navigation.NavBackStackEntry
|
|||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
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.MessagingRuleType
|
||||||
import me.rhunk.snapenhance.common.data.SocialScope
|
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.common.util.snap.BitmojiSelfie
|
||||||
|
import me.rhunk.snapenhance.storage.*
|
||||||
import me.rhunk.snapenhance.ui.manager.Routes
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.AlertDialogs
|
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.Dialog
|
||||||
|
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ -32,10 +38,10 @@ class ManageScope: Routes.Route() {
|
|||||||
|
|
||||||
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
|
private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
SocialScope.FRIEND -> context.modDatabase.deleteFriend(id)
|
SocialScope.FRIEND -> context.database.deleteFriend(id)
|
||||||
SocialScope.GROUP -> context.modDatabase.deleteGroup(id)
|
SocialScope.GROUP -> context.database.deleteGroup(id)
|
||||||
}
|
}
|
||||||
context.modDatabase.executeAsync {
|
context.database.executeAsync {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
routes.navController.popBackStack()
|
routes.navController.popBackStack()
|
||||||
}
|
}
|
||||||
@ -79,48 +85,98 @@ class ManageScope: Routes.Route() {
|
|||||||
val id = navBackStackEntry.arguments?.getString("id")!!
|
val id = navBackStackEntry.arguments?.getString("id")!!
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
when (scope) {
|
var hasScope by remember {
|
||||||
SocialScope.FRIEND -> Friend(id)
|
mutableStateOf(null as Boolean?)
|
||||||
SocialScope.GROUP -> Group(id)
|
|
||||||
}
|
}
|
||||||
|
when (scope) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
SocialScope.FRIEND -> {
|
||||||
|
var streaks by remember { mutableStateOf(null as FriendStreaks?) }
|
||||||
val rules = context.modDatabase.getRules(id)
|
val friend by rememberAsyncMutableState(null) {
|
||||||
|
context.database.getFriendInfo(id)?.also {
|
||||||
SectionTitle(translation["rules_title"])
|
streaks = context.database.getFriendStreaks(id)
|
||||||
|
}.also {
|
||||||
ContentCard {
|
hasScope = it != null
|
||||||
MessagingRuleType.entries.forEach { ruleType ->
|
}
|
||||||
var ruleEnabled by remember {
|
|
||||||
mutableStateOf(rules.any { it.key == ruleType.key })
|
|
||||||
}
|
}
|
||||||
|
friend?.let {
|
||||||
val ruleState = context.config.root.rules.getRuleState(ruleType)
|
Friend(id, it, streaks)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun Friend(id: String) {
|
private fun Friend(
|
||||||
//fetch the friend from the database
|
id: String,
|
||||||
val friend = remember { context.modDatabase.getFriendInfo(id) } ?: run {
|
friend: MessagingFriendInfo,
|
||||||
Text(text = translation["not_found"])
|
streaks: FriendStreaks?
|
||||||
return
|
) {
|
||||||
}
|
|
||||||
|
|
||||||
val streaks = remember {
|
|
||||||
context.modDatabase.getFriendStreaks(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(10.dp)
|
.padding(10.dp)
|
||||||
@ -275,7 +325,7 @@ class ManageScope: Routes.Route() {
|
|||||||
modifier = Modifier.padding(end = 10.dp)
|
modifier = Modifier.padding(end = 10.dp)
|
||||||
)
|
)
|
||||||
Switch(checked = shouldNotify, onCheckedChange = {
|
Switch(checked = shouldNotify, onCheckedChange = {
|
||||||
context.modDatabase.setFriendStreaksNotify(id, it)
|
context.database.setFriendStreaksNotify(id, it)
|
||||||
shouldNotify = it
|
shouldNotify = it
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -286,7 +336,9 @@ class ManageScope: Routes.Route() {
|
|||||||
|
|
||||||
if (context.config.root.experimental.e2eEncryption.globalState == true) {
|
if (context.config.root.experimental.e2eEncryption.globalState == true) {
|
||||||
SectionTitle(translation["e2ee_title"])
|
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) }
|
var importDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
if (importDialog) {
|
if (importDialog) {
|
||||||
@ -302,8 +354,11 @@ class ManageScope: Routes.Route() {
|
|||||||
return@runCatching
|
return@runCatching
|
||||||
}
|
}
|
||||||
|
|
||||||
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
|
context.coroutineScope.launch {
|
||||||
context.longToast("Successfully imported key")
|
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
|
||||||
|
context.longToast("Successfully imported key")
|
||||||
|
}
|
||||||
|
|
||||||
hasSecretKey = true
|
hasSecretKey = true
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.longToast("Failed to import key: ${it.message}")
|
context.longToast("Failed to import key: ${it.message}")
|
||||||
@ -320,20 +375,22 @@ class ManageScope: Routes.Route() {
|
|||||||
) {
|
) {
|
||||||
if (hasSecretKey) {
|
if (hasSecretKey) {
|
||||||
OutlinedButton(onClick = {
|
OutlinedButton(onClick = {
|
||||||
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton)
|
context.coroutineScope.launch {
|
||||||
//TODO: fingerprint auth
|
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
|
||||||
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
//TODO: fingerprint auth
|
||||||
action = Intent.ACTION_SEND
|
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
||||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
action = Intent.ACTION_SEND
|
||||||
type = "text/plain"
|
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||||
}, "").apply {
|
type = "text/plain"
|
||||||
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
}, "").apply {
|
||||||
Intent().apply {
|
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
||||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
Intent().apply {
|
||||||
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||||
})
|
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
||||||
)
|
})
|
||||||
})
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(
|
Text(
|
||||||
text = "Export Base64",
|
text = "Export Base64",
|
||||||
@ -355,13 +412,7 @@ class ManageScope: Routes.Route() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Group(id: String) {
|
private fun Group(group: MessagingGroupInfo) {
|
||||||
//fetch the group from the database
|
|
||||||
val group = remember { context.modDatabase.getGroupInfo(id) } ?: run {
|
|
||||||
Text(text = translation["not_found"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(10.dp)
|
.padding(10.dp)
|
||||||
|
@ -478,12 +478,14 @@ class MessagingPreview: Routes.Route() {
|
|||||||
|
|
||||||
isBridgeConnected = context.hasMessagingBridge()
|
isBridgeConnected = context.hasMessagingBridge()
|
||||||
if (isBridgeConnected) {
|
if (isBridgeConnected) {
|
||||||
onMessagingBridgeReady(scope, id)
|
withContext(Dispatchers.IO) {
|
||||||
} else {
|
onMessagingBridgeReady(scope, id)
|
||||||
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
|
|
||||||
context.androidContext.sendBroadcast(it)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
SnapWidgetBroadcastReceiverHelper.create("wakeup") {}.also {
|
||||||
|
context.androidContext.sendBroadcast(it)
|
||||||
|
}
|
||||||
withTimeout(10000) {
|
withTimeout(10000) {
|
||||||
while (!context.hasMessagingBridge()) {
|
while (!context.hasMessagingBridge()) {
|
||||||
delay(100)
|
delay(100)
|
||||||
|
@ -22,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.rhunk.snapenhance.R
|
import me.rhunk.snapenhance.R
|
||||||
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
|
||||||
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
|
||||||
import me.rhunk.snapenhance.common.data.SocialScope
|
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.BitmojiSelfie
|
||||||
|
import me.rhunk.snapenhance.storage.*
|
||||||
import me.rhunk.snapenhance.ui.manager.Routes
|
import me.rhunk.snapenhance.ui.manager.Routes
|
||||||
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
import me.rhunk.snapenhance.ui.util.coil.BitmojiImage
|
||||||
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
|
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 friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||||
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||||
|
|
||||||
fun updateScopeLists() {
|
private fun updateScopeLists() {
|
||||||
context.coroutineScope.launch(Dispatchers.IO) {
|
context.coroutineScope.launch {
|
||||||
friendList = context.modDatabase.getFriends(descOrder = true)
|
friendList = context.database.getFriends(descOrder = true)
|
||||||
groupList = context.modDatabase.getGroups()
|
groupList = context.database.getGroups()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val addFriendDialog by lazy {
|
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
|
@Composable
|
||||||
@ -82,7 +99,7 @@ class SocialRoot : Routes.Route() {
|
|||||||
SocialScope.FRIEND -> friendList[index].userId
|
SocialScope.FRIEND -> friendList[index].userId
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(10.dp)
|
.padding(10.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -119,12 +136,8 @@ class SocialRoot : Routes.Route() {
|
|||||||
|
|
||||||
SocialScope.FRIEND -> {
|
SocialScope.FRIEND -> {
|
||||||
val friend = friendList[index]
|
val friend = friendList[index]
|
||||||
var streaks by remember { mutableStateOf(friend.streaks) }
|
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
|
||||||
|
context.database.getFriendStreaks(friend.userId)
|
||||||
LaunchedEffect(friend.userId) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
streaks = context.modDatabase.getFriendStreaks(friend.userId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BitmojiImage(
|
BitmojiImage(
|
||||||
@ -204,6 +217,11 @@ class SocialRoot : Routes.Route() {
|
|||||||
addFriendDialog.Content {
|
addFriendDialog.Content {
|
||||||
showAddFriendDialog = false
|
showAddFriendDialog = false
|
||||||
}
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
updateScopeLists()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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
|
imageLoader = context.imageLoader
|
||||||
),
|
),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Inside,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.requiredWidthIn(min = 0.dp, max = size.dp)
|
.requiredWidthIn(min = 0.dp, max = size.dp)
|
||||||
.height(size.dp)
|
.height(size.dp)
|
||||||
|
@ -33,6 +33,8 @@
|
|||||||
"home_logs": "Logs",
|
"home_logs": "Logs",
|
||||||
"logger_history": "Logger History",
|
"logger_history": "Logger History",
|
||||||
"logged_stories": "Logged Stories",
|
"logged_stories": "Logged Stories",
|
||||||
|
"friend_tracker": "Friend Tracker",
|
||||||
|
"edit_rule": "Edit Rule",
|
||||||
"social": "Social",
|
"social": "Social",
|
||||||
"manage_scope": "Manage Scope",
|
"manage_scope": "Manage Scope",
|
||||||
"messaging_preview": "Preview",
|
"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": {
|
"spoof": {
|
||||||
"name": "Spoof",
|
"name": "Spoof",
|
||||||
"description": "Spoof various information about you",
|
"description": "Spoof various information about you",
|
||||||
@ -1039,6 +1027,20 @@
|
|||||||
"description": "Disables the anonymization of logs"
|
"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": {
|
"options": {
|
||||||
|
@ -1,16 +1,24 @@
|
|||||||
package me.rhunk.snapenhance.common.action
|
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(
|
enum class EnumAction(
|
||||||
val key: String,
|
val key: String,
|
||||||
|
val icon: ImageVector,
|
||||||
val exitOnFinish: Boolean = false,
|
val exitOnFinish: Boolean = false,
|
||||||
) {
|
) {
|
||||||
EXPORT_CHAT_MESSAGES("export_chat_messages"),
|
EXPORT_CHAT_MESSAGES("export_chat_messages", Icons.AutoMirrored.Default.Chat),
|
||||||
EXPORT_MEMORIES("export_memories"),
|
EXPORT_MEMORIES("export_memories", Icons.Default.Image),
|
||||||
BULK_MESSAGING_ACTION("bulk_messaging_action"),
|
BULK_MESSAGING_ACTION("bulk_messaging_action", Icons.Default.DeleteOutline),
|
||||||
MANAGE_FRIEND_LIST("manage_friend_list"),
|
CLEAN_CACHE("clean_snapchat_cache", Icons.Default.CleaningServices, exitOnFinish = true),
|
||||||
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true);
|
MANAGE_FRIEND_LIST("manage_friend_list", Icons.Default.PersonOutline);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_PARAMETER = "se_action"
|
const val ACTION_PARAMETER = "se_action"
|
||||||
|
@ -2,25 +2,34 @@ package me.rhunk.snapenhance.common.bridge.wrapper
|
|||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.JsonObject
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
|
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
|
||||||
import me.rhunk.snapenhance.common.data.StoryData
|
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.SQLiteDatabaseHelper
|
||||||
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
|
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
|
||||||
import me.rhunk.snapenhance.common.util.ktx.getIntOrNull
|
import me.rhunk.snapenhance.common.util.ktx.getIntOrNull
|
||||||
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
|
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
|
||||||
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||||
|
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
class LoggedMessageEdit(
|
||||||
|
val timestamp: Long,
|
||||||
|
val messageText: String
|
||||||
|
)
|
||||||
|
|
||||||
class LoggedMessage(
|
class LoggedMessage(
|
||||||
val messageId: Long,
|
val messageId: Long,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val messageData: ByteArray
|
val messageData: ByteArray,
|
||||||
)
|
)
|
||||||
|
|
||||||
class TrackerLog(
|
class TrackerLog(
|
||||||
|
val id: Int,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val conversationId: String,
|
val conversationId: String,
|
||||||
val conversationTitle: String?,
|
val conversationTitle: String?,
|
||||||
@ -37,6 +46,7 @@ class LoggerWrapper(
|
|||||||
private var _database: SQLiteDatabase? = null
|
private var _database: SQLiteDatabase? = null
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
|
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
|
||||||
|
private val gson by lazy { GsonBuilder().create() }
|
||||||
|
|
||||||
private val database get() = synchronized(this) {
|
private val database get() = synchronized(this) {
|
||||||
_database?.takeIf { it.isOpen } ?: run {
|
_database?.takeIf { it.isOpen } ?: run {
|
||||||
@ -50,6 +60,14 @@ class LoggerWrapper(
|
|||||||
"message_id BIGINT",
|
"message_id BIGINT",
|
||||||
"message_data BLOB"
|
"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(
|
"stories" to listOf(
|
||||||
"id INTEGER PRIMARY KEY",
|
"id INTEGER PRIMARY KEY",
|
||||||
"added_timestamp BIGINT",
|
"added_timestamp BIGINT",
|
||||||
@ -111,18 +129,66 @@ class LoggerWrapper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) {
|
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 hasMessage = database.rawQuery("SELECT message_id FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())).use {
|
||||||
val state = cursor.moveToFirst()
|
it.moveToFirst()
|
||||||
cursor.close()
|
it.count > 0
|
||||||
if (state) return
|
}
|
||||||
|
|
||||||
|
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 {
|
runBlocking {
|
||||||
withContext(coroutineScope.coroutineContext) {
|
withContext(coroutineScope.coroutineContext) {
|
||||||
database.insert("messages", null, ContentValues().apply {
|
runCatching {
|
||||||
put("added_timestamp", System.currentTimeMillis())
|
val messageObject = gson.fromJson(
|
||||||
put("conversation_id", conversationId)
|
serializedMessage.toString(Charsets.UTF_8),
|
||||||
put("message_id", messageId)
|
JsonObject::class.java
|
||||||
put("message_data", serializedMessage)
|
)
|
||||||
})
|
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 {
|
maxAge?.let {
|
||||||
val maxTime = System.currentTimeMillis() - it
|
val maxTime = System.currentTimeMillis() - it
|
||||||
database.execSQL("DELETE FROM messages WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
|
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()))
|
database.execSQL("DELETE FROM stories WHERE added_timestamp < ?", arrayOf(maxTime.toString()))
|
||||||
} ?: run {
|
} ?: run {
|
||||||
database.execSQL("DELETE FROM messages")
|
database.execSQL("DELETE FROM messages")
|
||||||
|
database.execSQL("DELETE FROM chat_edits")
|
||||||
database.execSQL("DELETE FROM stories")
|
database.execSQL("DELETE FROM stories")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,6 +225,7 @@ class LoggerWrapper(
|
|||||||
override fun deleteMessage(conversationId: String, messageId: Long) {
|
override fun deleteMessage(conversationId: String, messageId: Long) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
|
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(
|
fun getLogs(
|
||||||
lastTimestamp: Long,
|
lastTimestamp: Long,
|
||||||
filter: ((TrackerLog) -> Boolean)? = null
|
filter: ((TrackerLog) -> Boolean)? = null
|
||||||
@ -215,6 +290,7 @@ class LoggerWrapper(
|
|||||||
val logs = mutableListOf<TrackerLog>()
|
val logs = mutableListOf<TrackerLog>()
|
||||||
while (it.moveToNext() && logs.size < 50) {
|
while (it.moveToNext() && logs.size < 50) {
|
||||||
val log = TrackerLog(
|
val log = TrackerLog(
|
||||||
|
id = it.getIntOrNull("id") ?: continue,
|
||||||
timestamp = it.getLongOrNull("timestamp") ?: continue,
|
timestamp = it.getLongOrNull("timestamp") ?: continue,
|
||||||
conversationId = it.getStringOrNull("conversation_id") ?: continue,
|
conversationId = it.getStringOrNull("conversation_id") ?: continue,
|
||||||
conversationTitle = it.getStringOrNull("conversation_title"),
|
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(
|
fun fetchMessages(
|
||||||
conversationId: String,
|
conversationId: String,
|
||||||
fromTimestamp: Long,
|
fromTimestamp: Long,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package me.rhunk.snapenhance.common.config
|
package me.rhunk.snapenhance.common.config
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ class ConfigParams(
|
|||||||
private var _flags: Int? = null,
|
private var _flags: Int? = null,
|
||||||
private var _notices: Int? = null,
|
private var _notices: Int? = null,
|
||||||
|
|
||||||
var icon: String? = null,
|
var icon: ImageVector? = null,
|
||||||
var disabledKey: String? = null,
|
var disabledKey: String? = null,
|
||||||
var customTranslationPath: String? = null,
|
var customTranslationPath: String? = null,
|
||||||
var customOptionTranslationPath: String? = null,
|
var customOptionTranslationPath: String? = null,
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
package me.rhunk.snapenhance.common.config.impl
|
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.ConfigContainer
|
||||||
import me.rhunk.snapenhance.common.config.FeatureNotice
|
import me.rhunk.snapenhance.common.config.FeatureNotice
|
||||||
|
|
||||||
class Experimental : ConfigContainer() {
|
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) {
|
class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) {
|
||||||
val showFirstCreatedUsername = boolean("show_first_created_username")
|
val showFirstCreatedUsername = boolean("show_first_created_username")
|
||||||
val bypassCameraRollLimit = boolean("bypass_camera_roll_limit")
|
val bypassCameraRollLimit = boolean("bypass_camera_roll_limit")
|
||||||
@ -34,9 +32,8 @@ class Experimental : ConfigContainer() {
|
|||||||
val lockOnResume = boolean("lock_on_resume", defaultValue = true)
|
val lockOnResume = boolean("lock_on_resume", defaultValue = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() }
|
val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
|
||||||
val sessionEvents = container("session_events", SessionEventsConfig()) { requireRestart(); nativeHooks() }
|
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
|
||||||
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
|
|
||||||
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
|
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
|
||||||
val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() }
|
val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() }
|
||||||
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
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
|
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.ConfigContainer
|
||||||
import me.rhunk.snapenhance.common.config.FeatureNotice
|
import me.rhunk.snapenhance.common.config.FeatureNotice
|
||||||
|
|
||||||
class RootConfig : ConfigContainer() {
|
class RootConfig : ConfigContainer() {
|
||||||
val downloader = container("downloader", DownloaderConfig()) { icon = "Download"}
|
val downloader = container("downloader", DownloaderConfig()) { icon = Icons.Default.Download }
|
||||||
val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = "RemoveRedEye"}
|
val userInterface = container("user_interface", UserInterfaceTweaks()) { icon = Icons.Default.RemoveRedEye }
|
||||||
val messaging = container("messaging", MessagingTweaks()) { icon = "Send" }
|
val messaging = container("messaging", MessagingTweaks()) { icon = Icons.AutoMirrored.Default.Send }
|
||||||
val global = container("global", Global()) { icon = "MiscellaneousServices" }
|
val global = container("global", Global()) { icon = Icons.Default.MiscellaneousServices }
|
||||||
val rules = container("rules", Rules()) { icon = "Rule" }
|
val rules = container("rules", Rules()) { icon = Icons.AutoMirrored.Default.Rule }
|
||||||
val camera = container("camera", Camera()) { icon = "Camera"; requireRestart() }
|
val camera = container("camera", Camera()) { icon = Icons.Default.Camera; requireRestart() }
|
||||||
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = "Alarm" }
|
val streaksReminder = container("streaks_reminder", StreaksReminderConfig()) { icon = Icons.Default.Alarm }
|
||||||
val experimental = container("experimental", Experimental()) { icon = "Science"; addNotices(
|
val experimental = container("experimental", Experimental()) { icon = Icons.Default.Science; addNotices(
|
||||||
FeatureNotice.UNSTABLE) }
|
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_DELETED("message_deleted"),
|
||||||
MESSAGE_SAVED("message_saved"),
|
MESSAGE_SAVED("message_saved"),
|
||||||
MESSAGE_UNSAVED("message_unsaved"),
|
MESSAGE_UNSAVED("message_unsaved"),
|
||||||
|
MESSAGE_EDITED("message_edited"),
|
||||||
MESSAGE_REACTION_ADD("message_reaction_add"),
|
MESSAGE_REACTION_ADD("message_reaction_add"),
|
||||||
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
|
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
|
||||||
SNAP_OPENED("snap_opened"),
|
SNAP_OPENED("snap_opened"),
|
||||||
@ -44,70 +45,6 @@ enum class SessionEventType(
|
|||||||
SNAP_SCREEN_RECORD("snap_screen_record"),
|
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(
|
enum class TrackerEventType(
|
||||||
val key: String
|
val key: String
|
||||||
) {
|
) {
|
||||||
@ -126,6 +63,7 @@ enum class TrackerEventType(
|
|||||||
MESSAGE_DELETED("message_deleted"),
|
MESSAGE_DELETED("message_deleted"),
|
||||||
MESSAGE_SAVED("message_saved"),
|
MESSAGE_SAVED("message_saved"),
|
||||||
MESSAGE_UNSAVED("message_unsaved"),
|
MESSAGE_UNSAVED("message_unsaved"),
|
||||||
|
MESSAGE_EDITED("message_edited"),
|
||||||
MESSAGE_REACTION_ADD("message_reaction_add"),
|
MESSAGE_REACTION_ADD("message_reaction_add"),
|
||||||
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
|
MESSAGE_REACTION_REMOVE("message_reaction_remove"),
|
||||||
SNAP_OPENED("snap_opened"),
|
SNAP_OPENED("snap_opened"),
|
||||||
@ -134,3 +72,104 @@ enum class TrackerEventType(
|
|||||||
SNAP_SCREENSHOT("snap_screenshot"),
|
SNAP_SCREENSHOT("snap_screenshot"),
|
||||||
SNAP_SCREEN_RECORD("snap_screen_record"),
|
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 android.widget.Toast
|
||||||
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
|
import me.rhunk.snapenhance.common.scripting.bindings.AbstractBinding
|
||||||
import me.rhunk.snapenhance.common.scripting.bindings.BindingsContext
|
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.JavaInterfaces
|
||||||
|
import me.rhunk.snapenhance.common.scripting.impl.Networking
|
||||||
import me.rhunk.snapenhance.common.scripting.ktx.contextScope
|
import me.rhunk.snapenhance.common.scripting.ktx.contextScope
|
||||||
import me.rhunk.snapenhance.common.scripting.ktx.putFunction
|
import me.rhunk.snapenhance.common.scripting.ktx.putFunction
|
||||||
import me.rhunk.snapenhance.common.scripting.ktx.scriptable
|
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.ScriptableObject
|
||||||
import org.mozilla.javascript.Undefined
|
import org.mozilla.javascript.Undefined
|
||||||
import org.mozilla.javascript.Wrapper
|
import org.mozilla.javascript.Wrapper
|
||||||
|
import java.io.Reader
|
||||||
import java.lang.reflect.Modifier
|
import java.lang.reflect.Modifier
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class JSModule(
|
class JSModule(
|
||||||
val scriptRuntime: ScriptRuntime,
|
private val scriptRuntime: ScriptRuntime,
|
||||||
val moduleInfo: ModuleInfo,
|
val moduleInfo: ModuleInfo,
|
||||||
val content: String,
|
private val reader: Reader,
|
||||||
) {
|
) {
|
||||||
private val moduleBindings = mutableMapOf<String, AbstractBinding>()
|
private val moduleBindings = mutableMapOf<String, AbstractBinding>()
|
||||||
private lateinit var moduleObject: ScriptableObject
|
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(
|
registerBindings(
|
||||||
JavaInterfaces(),
|
JavaInterfaces(),
|
||||||
InterfaceManager(),
|
InterfaceManager(),
|
||||||
@ -186,7 +199,7 @@ class JSModule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
contextScope(shouldOptimize = true) {
|
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 {
|
private fun argsToString(args: Array<out Any?>?): String {
|
||||||
return args?.joinToString(" ") {
|
return args?.joinToString(" ") {
|
||||||
when (it) {
|
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()
|
else -> it.toString()
|
||||||
}
|
}
|
||||||
} ?: "null"
|
} ?: "null"
|
||||||
|
@ -3,11 +3,11 @@ package me.rhunk.snapenhance.common.scripting
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import me.rhunk.snapenhance.bridge.scripting.IScripting
|
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.logger.AbstractLogger
|
||||||
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
import me.rhunk.snapenhance.common.scripting.type.ModuleInfo
|
||||||
import org.mozilla.javascript.ScriptableObject
|
import org.mozilla.javascript.ScriptableObject
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
open class ScriptRuntime(
|
open class ScriptRuntime(
|
||||||
@ -35,7 +35,7 @@ open class ScriptRuntime(
|
|||||||
return modules.values.find { it.moduleInfo.name == name }
|
return modules.values.find { it.moduleInfo.name == name }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readModuleInfo(reader: BufferedReader): ModuleInfo {
|
fun readModuleInfo(reader: BufferedReader): ModuleInfo {
|
||||||
val header = reader.readLine()
|
val header = reader.readLine()
|
||||||
if (!header.startsWith("// ==SE_module==")) {
|
if (!header.startsWith("// ==SE_module==")) {
|
||||||
throw Exception("Invalid module header")
|
throw Exception("Invalid module header")
|
||||||
@ -74,6 +74,10 @@ open class ScriptRuntime(
|
|||||||
return readModuleInfo(inputStream.bufferedReader())
|
return readModuleInfo(inputStream.bufferedReader())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeModule(scriptPath: String) {
|
||||||
|
modules.remove(scriptPath)
|
||||||
|
}
|
||||||
|
|
||||||
fun unload(scriptPath: String) {
|
fun unload(scriptPath: String) {
|
||||||
val module = modules[scriptPath] ?: return
|
val module = modules[scriptPath] ?: return
|
||||||
logger.info("Unloading module $scriptPath")
|
logger.info("Unloading module $scriptPath")
|
||||||
@ -81,27 +85,30 @@ open class ScriptRuntime(
|
|||||||
modules.remove(scriptPath)
|
modules.remove(scriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(scriptPath: String, pfd: ParcelFileDescriptor) {
|
fun load(scriptPath: String, pfd: ParcelFileDescriptor): JSModule {
|
||||||
load(scriptPath, ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
|
return ParcelFileDescriptor.AutoCloseInputStream(pfd).use {
|
||||||
it.readBytes().toString(Charsets.UTF_8)
|
load(scriptPath, it)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(scriptPath: String, content: String): JSModule? {
|
fun load(scriptPath: String, content: InputStream): JSModule {
|
||||||
logger.info("Loading module $scriptPath")
|
logger.info("Loading module $scriptPath")
|
||||||
return runCatching {
|
val bufferedReader = content.bufferedReader()
|
||||||
JSModule(
|
val moduleInfo = readModuleInfo(bufferedReader)
|
||||||
scriptRuntime = this,
|
|
||||||
moduleInfo = readModuleInfo(ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)).bufferedReader()),
|
if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) {
|
||||||
content = content,
|
throw Exception("Module requires a newer version of SnapEnhance (min version: ${moduleInfo.minSEVersion})")
|
||||||
).apply {
|
}
|
||||||
load {
|
|
||||||
buildModuleObject(this, this@apply)
|
return JSModule(
|
||||||
}
|
scriptRuntime = this,
|
||||||
modules[scriptPath] = this
|
moduleInfo = moduleInfo,
|
||||||
|
reader = bufferedReader,
|
||||||
|
).apply {
|
||||||
|
load {
|
||||||
|
buildModuleObject(this, this@apply)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
modules[scriptPath] = this
|
||||||
logger.error("Failed to load module $scriptPath", it)
|
}
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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) {
|
return when (type) {
|
||||||
BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1"
|
BitmojiSelfieType.STANDARD -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?transparent=1"
|
||||||
BitmojiSelfieType.THREE_D -> "${type.prefixUrl}$selfieId-$avatarId-v1.webp?trim=circle"
|
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.LocaleWrapper
|
||||||
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
|
import me.rhunk.snapenhance.common.bridge.wrapper.MappingsWrapper
|
||||||
import me.rhunk.snapenhance.common.config.ModConfig
|
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.BridgeClient
|
||||||
import me.rhunk.snapenhance.core.bridge.loadFromBridge
|
import me.rhunk.snapenhance.core.bridge.loadFromBridge
|
||||||
import me.rhunk.snapenhance.core.database.DatabaseAccess
|
import me.rhunk.snapenhance.core.database.DatabaseAccess
|
||||||
import me.rhunk.snapenhance.core.event.EventBus
|
import me.rhunk.snapenhance.core.event.EventBus
|
||||||
import me.rhunk.snapenhance.core.event.EventDispatcher
|
import me.rhunk.snapenhance.core.event.EventDispatcher
|
||||||
import me.rhunk.snapenhance.core.features.Feature
|
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.features.FeatureManager
|
||||||
|
import me.rhunk.snapenhance.core.logger.CoreLogger
|
||||||
import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge
|
import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge
|
||||||
import me.rhunk.snapenhance.core.messaging.MessageSender
|
import me.rhunk.snapenhance.core.messaging.MessageSender
|
||||||
import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime
|
import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime
|
||||||
|
@ -36,11 +36,13 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.rhunk.snapenhance.common.data.ContentType
|
import me.rhunk.snapenhance.common.data.ContentType
|
||||||
import me.rhunk.snapenhance.common.data.FriendLinkType
|
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.database.impl.FriendInfo
|
||||||
import me.rhunk.snapenhance.common.messaging.MessagingConstraints
|
import me.rhunk.snapenhance.common.messaging.MessagingConstraints
|
||||||
import me.rhunk.snapenhance.common.messaging.MessagingTask
|
import me.rhunk.snapenhance.common.messaging.MessagingTask
|
||||||
import me.rhunk.snapenhance.common.messaging.MessagingTaskType
|
import me.rhunk.snapenhance.common.messaging.MessagingTaskType
|
||||||
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
|
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.ktx.copyToClipboard
|
||||||
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
|
||||||
import me.rhunk.snapenhance.core.action.AbstractAction
|
import me.rhunk.snapenhance.core.action.AbstractAction
|
||||||
@ -62,6 +64,8 @@ class BulkMessagingAction : AbstractAction() {
|
|||||||
ADDED_TIMESTAMP,
|
ADDED_TIMESTAMP,
|
||||||
SNAP_SCORE,
|
SNAP_SCORE,
|
||||||
STREAK_LENGTH,
|
STREAK_LENGTH,
|
||||||
|
MOST_MESSAGES_SENT,
|
||||||
|
MOST_RECENT_MESSAGE,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Filter {
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BulkMessagingDialog() {
|
private fun BulkMessagingDialog() {
|
||||||
@ -198,6 +208,12 @@ class BulkMessagingAction : AbstractAction() {
|
|||||||
SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp }
|
SortBy.ADDED_TIMESTAMP -> newFriends.sortBy { it.addedTimestamp }
|
||||||
SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore }
|
SortBy.SNAP_SCORE -> newFriends.sortBy { it.snapScore }
|
||||||
SortBy.STREAK_LENGTH -> newFriends.sortBy { it.streakLength }
|
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()
|
if (sortReverseOrder) newFriends.reverse()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@ -288,7 +304,7 @@ class BulkMessagingAction : AbstractAction() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||||
) {
|
) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
Row(
|
Row(
|
||||||
@ -398,10 +414,14 @@ class BulkMessagingAction : AbstractAction() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
){
|
){
|
||||||
Text(text = (friendInfo.displayName ?: friendInfo.mutableUsername).toString(), fontSize = 16.sp, fontWeight = FontWeight.Bold, 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)
|
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 {
|
buildString {
|
||||||
append("Relationship: ")
|
append("Relationship: ")
|
||||||
append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"])
|
append(context.translation["friendship_link_type.${FriendLinkType.fromValue(friendInfo.friendLinkType).shortName}"])
|
||||||
@ -414,9 +434,13 @@ class BulkMessagingAction : AbstractAction() {
|
|||||||
friendInfo.streakLength.takeIf { it > 0 }?.let {
|
friendInfo.streakLength.takeIf { it > 0 }?.let {
|
||||||
append("\nStreaks length: $it")
|
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(
|
Checkbox(
|
||||||
|
@ -20,7 +20,6 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import me.rhunk.snapenhance.common.action.EnumAction
|
|
||||||
import me.rhunk.snapenhance.common.data.FriendLinkType
|
import me.rhunk.snapenhance.common.data.FriendLinkType
|
||||||
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
|
import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
|
||||||
import me.rhunk.snapenhance.core.action.AbstractAction
|
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.experiments.*
|
||||||
import me.rhunk.snapenhance.core.features.impl.global.*
|
import me.rhunk.snapenhance.core.features.impl.global.*
|
||||||
import me.rhunk.snapenhance.core.features.impl.messaging.*
|
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.HalfSwipeNotifier
|
||||||
import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger
|
import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger
|
||||||
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
|
import me.rhunk.snapenhance.core.features.impl.spying.StealthMode
|
||||||
@ -112,7 +113,7 @@ class FeatureManager(
|
|||||||
OperaViewerParamsOverride(),
|
OperaViewerParamsOverride(),
|
||||||
StealthModeIndicator(),
|
StealthModeIndicator(),
|
||||||
DisablePermissionRequests(),
|
DisablePermissionRequests(),
|
||||||
SessionEvents(),
|
FriendTracker(),
|
||||||
DefaultVolumeControls(),
|
DefaultVolumeControls(),
|
||||||
CallRecorder(),
|
CallRecorder(),
|
||||||
DisableMemoriesSnapFeed(),
|
DisableMemoriesSnapFeed(),
|
||||||
|
@ -2,8 +2,8 @@ package me.rhunk.snapenhance.core.features.impl
|
|||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import me.rhunk.snapenhance.common.data.StoryData
|
|
||||||
import me.rhunk.snapenhance.common.data.MixerStoryType
|
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.common.util.protobuf.ProtoEditor
|
||||||
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
|
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
|
||||||
import me.rhunk.snapenhance.core.features.Feature
|
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.Feature
|
||||||
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
||||||
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
|
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) {
|
class ProfilePictureDownloader : Feature("ProfilePictureDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||||
@SuppressLint("SetTextI18n")
|
@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.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
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.Constants
|
||||||
import me.rhunk.snapenhance.common.data.*
|
import me.rhunk.snapenhance.common.data.*
|
||||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
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.HookStage
|
||||||
import me.rhunk.snapenhance.core.util.hook.hook
|
import me.rhunk.snapenhance.core.util.hook.hook
|
||||||
import me.rhunk.snapenhance.core.util.hook.hookConstructor
|
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.core.wrapper.impl.toSnapUUID
|
||||||
import me.rhunk.snapenhance.nativelib.NativeLib
|
import me.rhunk.snapenhance.nativelib.NativeLib
|
||||||
import java.lang.reflect.Method
|
import java.lang.reflect.Method
|
||||||
import java.nio.ByteBuffer
|
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 conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
|
||||||
private val tracker by lazy { context.bridgeClient.getTracker() }
|
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? {
|
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
@ -32,14 +43,14 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
|||||||
}.getOrNull()
|
}.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) {
|
private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) {
|
||||||
context.androidContext.getSystemService(NotificationManager::class.java).notify(
|
notificationManager.notify(
|
||||||
id,
|
id,
|
||||||
Notification.Builder(
|
Notification.Builder(
|
||||||
context.androidContext,
|
context.androidContext,
|
||||||
"general_group_generic_push_noisy_generic_push_B~LVSD2"
|
"friend_tracker"
|
||||||
)
|
)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@ -62,6 +73,49 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
|||||||
context.log.verbose("volatile event\n$protoReader")
|
context.log.verbose("volatile event\n$protoReader")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun dispatchEvents(
|
||||||
|
eventType: TrackerEventType,
|
||||||
|
conversationId: String,
|
||||||
|
userId: String,
|
||||||
|
extras: String = ""
|
||||||
|
) {
|
||||||
|
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
|
||||||
|
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
|
||||||
|
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
|
||||||
|
|
||||||
|
context.log.verbose("$authorName $eventType in $conversationName")
|
||||||
|
|
||||||
|
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) ->
|
||||||
|
if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach
|
||||||
|
if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach
|
||||||
|
if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach
|
||||||
|
if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach
|
||||||
|
|
||||||
|
context.log.verbose("dispatching $action for $eventType in $conversationName")
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
TrackerRuleAction.PUSH_NOTIFICATION -> {
|
||||||
|
if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach
|
||||||
|
sendInfoNotification(text = "$authorName $eventType in $conversationName")
|
||||||
|
}
|
||||||
|
TrackerRuleAction.IN_APP_NOTIFICATION -> context.inAppOverlay.showStatusToast(
|
||||||
|
icon = Icons.Default.Info,
|
||||||
|
text = "$authorName $eventType in $conversationName"
|
||||||
|
)
|
||||||
|
TrackerRuleAction.LOG -> context.bridgeClient.getMessageLogger().logTrackerEvent(
|
||||||
|
conversationId,
|
||||||
|
conversationName,
|
||||||
|
context.database.getConversationType(conversationId) == 1,
|
||||||
|
authorName,
|
||||||
|
userId,
|
||||||
|
eventType.key,
|
||||||
|
extras
|
||||||
|
)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
|
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
|
||||||
context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState")
|
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
|
else -> null
|
||||||
} ?: return
|
} ?: return
|
||||||
|
|
||||||
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
|
dispatchEvents(eventType, conversationId, userId)
|
||||||
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
|
|
||||||
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
|
|
||||||
|
|
||||||
context.log.verbose("$authorName $eventType in $conversationName")
|
|
||||||
|
|
||||||
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.apply {
|
|
||||||
if (hasFlags(TrackerFlags.APP_IS_ACTIVE) && context.isMainActivityPaused) return
|
|
||||||
if (hasFlags(TrackerFlags.APP_IS_INACTIVE) && !context.isMainActivityPaused) return
|
|
||||||
if (hasFlags(TrackerFlags.IS_IN_CONVERSATION) && !isInConversation(conversationId)) return
|
|
||||||
if (hasFlags(TrackerFlags.NOTIFY)) sendInfoNotification(text = "$authorName $eventType in $conversationName")
|
|
||||||
if (hasFlags(TrackerFlags.LOG)) {
|
|
||||||
context.bridgeClient.getMessageLogger().logTrackerEvent(
|
|
||||||
conversationId,
|
|
||||||
conversationName,
|
|
||||||
context.database.getConversationType(conversationId) == 1,
|
|
||||||
authorName,
|
|
||||||
userId,
|
|
||||||
eventType.key,
|
|
||||||
""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onConversationMessagingEvent(event: SessionEvent) {
|
private fun onConversationMessagingEvent(event: SessionEvent) {
|
||||||
context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}")
|
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) {
|
val eventType = when(event.type) {
|
||||||
SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ
|
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_REACTION_REMOVE -> TrackerEventType.MESSAGE_REACTION_REMOVE
|
||||||
SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED
|
SessionEventType.MESSAGE_SAVED -> TrackerEventType.MESSAGE_SAVED
|
||||||
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
|
SessionEventType.MESSAGE_UNSAVED -> TrackerEventType.MESSAGE_UNSAVED
|
||||||
|
SessionEventType.MESSAGE_EDITED -> TrackerEventType.MESSAGE_EDITED
|
||||||
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
|
SessionEventType.SNAP_OPENED -> TrackerEventType.SNAP_OPENED
|
||||||
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
|
SessionEventType.SNAP_REPLAYED -> TrackerEventType.SNAP_REPLAYED
|
||||||
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
|
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
|
||||||
@ -125,36 +151,19 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
|||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
|
|
||||||
val messageEvents = arrayOf(
|
val conversationMessage by lazy {
|
||||||
TrackerEventType.MESSAGE_READ,
|
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
|
||||||
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
|
|
||||||
} ?: ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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) {
|
protoReader.followPath(6, 2) {
|
||||||
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
||||||
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
|
||||||
@ -282,7 +306,7 @@ class SessionEvents : Feature("Session Events", loadParams = FeatureLoadParams.I
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun init() {
|
override fun init() {
|
||||||
val sessionEventsConfig = context.config.experimental.sessionEvents
|
val sessionEventsConfig = context.config.friendTracker
|
||||||
if (sessionEventsConfig.globalState != true) return
|
if (sessionEventsConfig.globalState != true) return
|
||||||
|
|
||||||
if (sessionEventsConfig.allowRunningInBackground.get()) {
|
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 {
|
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
|
||||||
hook("onReceive", HookStage.BEFORE) { param ->
|
hook("onReceive", HookStage.BEFORE) { param ->
|
||||||
param.setResult(null)
|
param.setResult(null)
|
@ -119,8 +119,10 @@ class MessageLogger : Feature("MessageLogger",
|
|||||||
|
|
||||||
if (!isMessageDeleted) {
|
if (!isMessageDeleted) {
|
||||||
if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe
|
if (messageFilter.isNotEmpty() && !messageFilter.contains(messageContentType?.name)) return@subscribe
|
||||||
if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe
|
if (event.message.messageMetadata?.isEdited != true) {
|
||||||
fetchedMessages.add(uniqueMessageIdentifier)
|
if (fetchedMessages.contains(uniqueMessageIdentifier)) return@subscribe
|
||||||
|
fetchedMessages.add(uniqueMessageIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
threadPool.execute {
|
threadPool.execute {
|
||||||
try {
|
try {
|
||||||
|
@ -19,12 +19,10 @@ abstract class AbstractWrapper(
|
|||||||
inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) {
|
inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
operator fun getValue(obj: Any, property: KProperty<*>): T? {
|
operator fun getValue(obj: Any, property: KProperty<*>): T? {
|
||||||
val value = runCatching { XposedHelpers.getObjectField(instance, fieldName) }.getOrNull()
|
return runCatching {
|
||||||
return if (mapper != null) {
|
val value = XposedHelpers.getObjectField(instance, fieldName)
|
||||||
mapper.invoke(value)
|
mapper?.invoke(value) ?: value as? T
|
||||||
} else {
|
}.getOrNull()
|
||||||
value as? T
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) {
|
operator fun setValue(obj: Any, property: KProperty<*>, value: Any?) {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
package me.rhunk.snapenhance.mapper.impl
|
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.AbstractClassMapper
|
||||||
import me.rhunk.snapenhance.mapper.ext.getClassName
|
import me.rhunk.snapenhance.mapper.ext.getClassName
|
||||||
import me.rhunk.snapenhance.mapper.ext.getStaticConstructor
|
import me.rhunk.snapenhance.mapper.ext.getStaticConstructor
|
||||||
import me.rhunk.snapenhance.mapper.ext.isFinal
|
import me.rhunk.snapenhance.mapper.ext.isFinal
|
||||||
import com.android.tools.smali.dexlib2.iface.instruction.formats.ArrayPayload
|
|
||||||
|
|
||||||
class BCryptClassMapper : AbstractClassMapper("BCryptClass") {
|
class BCryptClassMapper : AbstractClassMapper("BCryptClass") {
|
||||||
val classReference = classReference("class")
|
val classReference = classReference("class")
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
package me.rhunk.snapenhance.mapper.impl
|
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.AbstractClassMapper
|
||||||
import me.rhunk.snapenhance.mapper.ext.findConstString
|
import me.rhunk.snapenhance.mapper.ext.findConstString
|
||||||
import me.rhunk.snapenhance.mapper.ext.getClassName
|
import me.rhunk.snapenhance.mapper.ext.getClassName
|
||||||
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
|
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
|
||||||
import me.rhunk.snapenhance.mapper.ext.isEnum
|
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
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") {
|
class CompositeConfigurationProviderMapper : AbstractClassMapper("CompositeConfigurationProvider") {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package me.rhunk.snapenhance.mapper.impl
|
package me.rhunk.snapenhance.mapper.impl
|
||||||
|
|
||||||
|
import com.android.tools.smali.dexlib2.AccessFlags
|
||||||
import me.rhunk.snapenhance.mapper.AbstractClassMapper
|
import me.rhunk.snapenhance.mapper.AbstractClassMapper
|
||||||
import me.rhunk.snapenhance.mapper.ext.getClassName
|
import me.rhunk.snapenhance.mapper.ext.getClassName
|
||||||
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
|
import me.rhunk.snapenhance.mapper.ext.hasStaticConstructorString
|
||||||
import me.rhunk.snapenhance.mapper.ext.isAbstract
|
import me.rhunk.snapenhance.mapper.ext.isAbstract
|
||||||
import me.rhunk.snapenhance.mapper.ext.isEnum
|
import me.rhunk.snapenhance.mapper.ext.isEnum
|
||||||
import com.android.tools.smali.dexlib2.AccessFlags
|
|
||||||
|
|
||||||
class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") {
|
class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelProvider") {
|
||||||
val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider")
|
val mediaQualityLevelProvider = classReference("mediaQualityLevelProvider")
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package me.rhunk.snapenhance.mapper.impl
|
package me.rhunk.snapenhance.mapper.impl
|
||||||
|
|
||||||
import com.android.tools.smali.dexlib2.iface.Method
|
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.AbstractClassMapper
|
||||||
import me.rhunk.snapenhance.mapper.ext.findConstString
|
import me.rhunk.snapenhance.mapper.ext.findConstString
|
||||||
import me.rhunk.snapenhance.mapper.ext.getClassName
|
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") {
|
class OperaViewerParamsMapper : AbstractClassMapper("OperaViewerParams") {
|
||||||
val classReference = classReference("class")
|
val classReference = classReference("class")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user