feat: social section

- bridge: rules, sync
- move extensions to a new package
- snap widget broadcast receiver (SnapWidgetBroadcastReceiverHelper)
- refactor compose remember delegates
This commit is contained in:
rhunk
2023-08-19 11:58:41 +02:00
parent 11b7119f8b
commit 8a19f27d96
61 changed files with 972 additions and 522 deletions

View File

@ -3,17 +3,25 @@ package me.rhunk.snapenhance.bridge
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.types.FileActionType import me.rhunk.snapenhance.bridge.types.FileActionType
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper import me.rhunk.snapenhance.bridge.wrapper.MessageLoggerWrapper
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.RuleScope
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.util.SerializableDataObject
import kotlin.system.measureTimeMillis
class BridgeService : Service() { class BridgeService : Service() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper private lateinit var messageLoggerWrapper: MessageLoggerWrapper
private lateinit var remoteSideContext: RemoteSideContext private lateinit var remoteSideContext: RemoteSideContext
private lateinit var syncCallback: SyncCallback
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder {
remoteSideContext = SharedContextHolder.remote(this).apply { remoteSideContext = SharedContextHolder.remote(this).apply {
@ -25,28 +33,36 @@ class BridgeService : Service() {
inner class BridgeBinder : BridgeInterface.Stub() { inner class BridgeBinder : BridgeInterface.Stub() {
override fun fileOperation(action: Int, fileType: Int, content: ByteArray?): ByteArray { override fun fileOperation(action: Int, fileType: Int, content: ByteArray?): ByteArray {
val resolvedFile by lazy { BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService) } val resolvedFile by lazy {
BridgeFileType.fromValue(fileType)?.resolve(this@BridgeService)
}
return when (FileActionType.values()[action]) { return when (FileActionType.values()[action]) {
FileActionType.CREATE_AND_READ -> { FileActionType.CREATE_AND_READ -> {
resolvedFile?.let { resolvedFile?.let {
if (!it.exists()) { if (!it.exists()) {
return content?.also { content -> it.writeBytes(content) } ?: ByteArray(0) return content?.also { content -> it.writeBytes(content) } ?: ByteArray(
0
)
} }
it.readBytes() it.readBytes()
} ?: ByteArray(0) } ?: ByteArray(0)
} }
FileActionType.READ -> { FileActionType.READ -> {
resolvedFile?.takeIf { it.exists() }?.readBytes() ?: ByteArray(0) resolvedFile?.takeIf { it.exists() }?.readBytes() ?: ByteArray(0)
} }
FileActionType.WRITE -> { FileActionType.WRITE -> {
content?.also { resolvedFile?.writeBytes(content) } ?: ByteArray(0) content?.also { resolvedFile?.writeBytes(content) } ?: ByteArray(0)
} }
FileActionType.DELETE -> { FileActionType.DELETE -> {
resolvedFile?.takeIf { it.exists() }?.delete() resolvedFile?.takeIf { it.exists() }?.delete()
ByteArray(0) ByteArray(0)
} }
FileActionType.EXISTS -> { FileActionType.EXISTS -> {
if (resolvedFile?.exists() == true) if (resolvedFile?.exists() == true)
ByteArray(1) ByteArray(1)
@ -55,27 +71,76 @@ class BridgeService : Service() {
} }
} }
override fun getLoggedMessageIds(conversationId: String, limit: Int) = messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray() override fun getLoggedMessageIds(conversationId: String, limit: Int) =
messageLoggerWrapper.getMessageIds(conversationId, limit).toLongArray()
override fun getMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.getMessage(conversationId, id).second override fun getMessageLoggerMessage(conversationId: String, id: Long) =
messageLoggerWrapper.getMessage(conversationId, id).second
override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) { override fun addMessageLoggerMessage(conversationId: String, id: Long, message: ByteArray) {
messageLoggerWrapper.addMessage(conversationId, id, message) messageLoggerWrapper.addMessage(conversationId, id, message)
} }
override fun deleteMessageLoggerMessage(conversationId: String, id: Long) = messageLoggerWrapper.deleteMessage(conversationId, id) override fun deleteMessageLoggerMessage(conversationId: String, id: Long) =
messageLoggerWrapper.deleteMessage(conversationId, id)
override fun clearMessageLogger() = messageLoggerWrapper.clearMessages() override fun clearMessageLogger() = messageLoggerWrapper.clearMessages()
override fun fetchLocales(userLocale: String) = LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate { override fun fetchLocales(userLocale: String) =
it.locale to it.content LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate {
} it.locale to it.content
}
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) { override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
DownloadProcessor( DownloadProcessor(
remoteSideContext = SharedContextHolder.remote(this@BridgeService), remoteSideContext = remoteSideContext,
callback = callback callback = callback
).onReceive(intent) ).onReceive(intent)
} }
override fun getRules(objectType: String, uuid: String): MutableList<String> {
remoteSideContext.modDatabase.getRulesFromId(RuleScope.valueOf(objectType), uuid)
.let { rules ->
return rules.map { it.toJson() }.toMutableList()
}
}
override fun sync(callback: SyncCallback) {
Logger.debug("Syncing remote")
syncCallback = callback
measureTimeMillis {
remoteSideContext.modDatabase.getFriendsIds().forEach { friendId ->
runCatching {
SerializableDataObject.fromJson<FriendInfo>(callback.syncFriend(friendId)).let {
remoteSideContext.modDatabase.syncFriend(it)
}
}.onFailure {
Logger.error("Failed to sync friend $friendId", it)
}
}
remoteSideContext.modDatabase.getGroupsIds().forEach { groupId ->
runCatching {
SerializableDataObject.fromJson<MessagingGroupInfo>(callback.syncGroup(groupId)).let {
remoteSideContext.modDatabase.syncGroupInfo(it)
}
}.onFailure {
Logger.error("Failed to sync group $groupId", it)
}
}
}.also {
Logger.debug("Syncing remote took $it ms")
}
}
override fun passGroupsAndFriends(
groups: List<String>,
friends: List<String>
) {
Logger.debug("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.modDatabase.receiveMessagingDataCallback(
friends.map { SerializableDataObject.fromJson<MessagingFriendInfo>(it) },
groups.map { SerializableDataObject.fromJson<MessagingGroupInfo>(it) }
)
}
} }
} }

View File

@ -1,16 +1,19 @@
package me.rhunk.snapenhance.messaging package me.rhunk.snapenhance.messaging
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.core.messaging.FriendStreaks import me.rhunk.snapenhance.core.messaging.FriendStreaks
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.core.messaging.MessagingRule import me.rhunk.snapenhance.core.messaging.MessagingRule
import me.rhunk.snapenhance.core.messaging.Mode import me.rhunk.snapenhance.core.messaging.Mode
import me.rhunk.snapenhance.core.messaging.ObjectType import me.rhunk.snapenhance.core.messaging.RuleScope
import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getLongOrNull import me.rhunk.snapenhance.util.ktx.getLongOrNull
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -20,19 +23,27 @@ class ModDatabase(
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
private lateinit var database: SQLiteDatabase private lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
fun init() { fun init() {
database = context.androidContext.openOrCreateDatabase("main.db", 0, null) database = context.androidContext.openOrCreateDatabase("main.db", 0, null)
SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf(
"friends" to listOf( "friends" to listOf(
"userId VARCHAR PRIMARY KEY", "userId VARCHAR PRIMARY KEY",
"displayName VARCHAR", "displayName VARCHAR",
"mutable_username VARCHAR", "mutableUsername VARCHAR",
"bitmojiId VARCHAR", "bitmojiId VARCHAR",
"selfieId VARCHAR" "selfieId VARCHAR"
), ),
"groups" to listOf(
"conversationId VARCHAR PRIMARY KEY",
"name VARCHAR",
"participantsCount INTEGER"
),
"rules" to listOf( "rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT", "id INTEGER PRIMARY KEY AUTOINCREMENT",
"objectType VARCHAR", "scope VARCHAR",
"targetUuid VARCHAR", "targetUuid VARCHAR",
"enabled BOOLEAN", "enabled BOOLEAN",
"mode VARCHAR", "mode VARCHAR",
@ -59,27 +70,94 @@ class ModDatabase(
)) ))
} }
fun syncFriends(friends: List<FriendInfo>) { fun getFriendsIds(): List<String> {
return database.rawQuery("SELECT userId FROM friends", null).use { cursor ->
val ids = mutableListOf<String>()
while (cursor.moveToNext()) {
ids.add(cursor.getString(0))
}
ids
}
}
fun getGroupsIds(): List<String> {
return database.rawQuery("SELECT conversationId FROM groups", null).use { cursor ->
val ids = mutableListOf<String>()
while (cursor.moveToNext()) {
ids.add(cursor.getString(0))
}
ids
}
}
fun getGroups(): List<MessagingGroupInfo> {
return database.rawQuery("SELECT * FROM groups", null).use { cursor ->
val groups = mutableListOf<MessagingGroupInfo>()
while (cursor.moveToNext()) {
groups.add(MessagingGroupInfo(
conversationId = cursor.getStringOrNull("conversationId")!!,
name = cursor.getStringOrNull("name")!!,
participantsCount = cursor.getInteger("participantsCount")
))
}
groups
}
}
fun getFriends(): List<MessagingFriendInfo> {
return database.rawQuery("SELECT * FROM friends", null).use { cursor ->
val friends = mutableListOf<MessagingFriendInfo>()
while (cursor.moveToNext()) {
runCatching {
friends.add(MessagingFriendInfo(
userId = cursor.getStringOrNull("userId")!!,
displayName = cursor.getStringOrNull("displayName"),
mutableUsername = cursor.getStringOrNull("mutableUsername")!!,
bitmojiId = cursor.getStringOrNull("bitmojiId"),
selfieId = cursor.getStringOrNull("selfieId")
))
}.onFailure {
Logger.error("Failed to parse friend", it)
}
}
friends
}
}
fun syncGroupInfo(conversationInfo: MessagingGroupInfo) {
executor.execute { executor.execute {
try { try {
friends.forEach { friend -> database.execSQL("INSERT OR REPLACE INTO groups VALUES (?, ?, ?)", arrayOf(
database.execSQL("INSERT OR REPLACE INTO friends VALUES (?, ?, ?, ?, ?)", arrayOf( conversationInfo.conversationId,
conversationInfo.name,
conversationInfo.participantsCount
))
} catch (e: Exception) {
throw e
}
}
}
fun syncFriend(friend: FriendInfo) {
executor.execute {
try {
database.execSQL("INSERT OR REPLACE INTO friends VALUES (?, ?, ?, ?, ?)", arrayOf(
friend.userId,
friend.displayName,
friend.usernameForSorting!!.split("|")[1],
friend.bitmojiAvatarId,
friend.bitmojiSelfieId
))
//sync streaks
if (friend.streakLength > 0) {
database.execSQL("INSERT OR REPLACE INTO streaks (userId, expirationTimestamp, count) VALUES (?, ?, ?)", arrayOf(
friend.userId, friend.userId,
friend.displayName, friend.streakExpirationTimestamp,
friend.username, friend.streakLength
friend.bitmojiAvatarId,
friend.bitmojiSelfieId
)) ))
//sync streaks } else {
if (friend.streakLength > 0) { database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(friend.userId))
database.execSQL("INSERT OR REPLACE INTO streaks (userId, expirationTimestamp, count) VALUES (?, ?, ?)", arrayOf(
friend.userId,
friend.streakExpirationTimestamp,
friend.streakLength
))
} else {
database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(friend.userId))
}
} }
} catch (e: Exception) { } catch (e: Exception) {
throw e throw e
@ -87,13 +165,13 @@ class ModDatabase(
} }
} }
fun getRulesFromId(type: ObjectType, targetUuid: String): List<MessagingRule> { fun getRulesFromId(type: RuleScope, targetUuid: String): List<MessagingRule> {
return database.rawQuery("SELECT * FROM rules WHERE objectType = ? AND targetUuid = ?", arrayOf(type.name, targetUuid)).use { cursor -> return database.rawQuery("SELECT * FROM rules WHERE objectType = ? AND targetUuid = ?", arrayOf(type.name, targetUuid)).use { cursor ->
val rules = mutableListOf<MessagingRule>() val rules = mutableListOf<MessagingRule>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
rules.add(MessagingRule( rules.add(MessagingRule(
id = cursor.getInteger("id"), id = cursor.getInteger("id"),
objectType = ObjectType.valueOf(cursor.getStringOrNull("objectType")!!), ruleScope = RuleScope.valueOf(cursor.getStringOrNull("scope")!!),
targetUuid = cursor.getStringOrNull("targetUuid")!!, targetUuid = cursor.getStringOrNull("targetUuid")!!,
enabled = cursor.getInteger("enabled") == 1, enabled = cursor.getInteger("enabled") == 1,
mode = Mode.valueOf(cursor.getStringOrNull("mode")!!), mode = Mode.valueOf(cursor.getStringOrNull("mode")!!),

View File

@ -17,6 +17,7 @@ import me.rhunk.snapenhance.ui.manager.sections.HomeSection
import me.rhunk.snapenhance.ui.manager.sections.NotImplemented import me.rhunk.snapenhance.ui.manager.sections.NotImplemented
import me.rhunk.snapenhance.ui.manager.sections.downloads.DownloadsSection import me.rhunk.snapenhance.ui.manager.sections.downloads.DownloadsSection
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
import me.rhunk.snapenhance.ui.manager.sections.social.SocialSection
import kotlin.reflect.KClass import kotlin.reflect.KClass
enum class EnumSection( enum class EnumSection(
@ -39,9 +40,10 @@ enum class EnumSection(
icon = Icons.Filled.Home, icon = Icons.Filled.Home,
section = HomeSection::class section = HomeSection::class
), ),
FRIENDS( SOCIAL(
route = "friends", route = "social",
icon = Icons.Filled.Group icon = Icons.Filled.Group,
section = SocialSection::class
), ),
PLUGINS( PLUGINS(
route = "plugins", route = "plugins",

View File

@ -34,9 +34,11 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.blur import androidx.compose.ui.draw.blur
@ -102,15 +104,15 @@ class DownloadsSection : Section() {
@Composable @Composable
private fun FilterList() { private fun FilterList() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu.value = !showMenu.value}) { IconButton(onClick = { showMenu = !showMenu}) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = null contentDescription = null
) )
} }
DropdownMenu(expanded = showMenu.value, onDismissRequest = { showMenu.value = false }) { DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
MediaFilter.values().toList().forEach { filter -> MediaFilter.values().toList().forEach { filter ->
DropdownMenuItem( DropdownMenuItem(
text = { text = {
@ -130,7 +132,7 @@ class DownloadsSection : Section() {
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
loadByFilter(filter) loadByFilter(filter)
showMenu.value = false showMenu = false
} }
} }
) )

View File

@ -18,8 +18,10 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -72,14 +74,14 @@ class Dialogs(
add(0, "null") add(0, "null")
} }
val selectedValue = remember { var selectedValue by remember {
mutableStateOf(property.value.getNullable()?.toString() ?: "null") mutableStateOf(property.value.getNullable()?.toString() ?: "null")
} }
DefaultDialogCard { DefaultDialogCard {
keys.forEachIndexed { index, item -> keys.forEachIndexed { index, item ->
fun select() { fun select() {
selectedValue.value = item selectedValue = item
property.value.setAny(if (index == 0) { property.value.setAny(if (index == 0) {
null null
} else { } else {
@ -97,7 +99,7 @@ class Dialogs(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
RadioButton( RadioButton(
selected = selectedValue.value == item, selected = selectedValue == item,
onClick = { select() } onClick = { select() }
) )
} }
@ -179,13 +181,11 @@ class Dialogs(
val toggledStates = property.value.get() as MutableList<String> val toggledStates = property.value.get() as MutableList<String>
DefaultDialogCard { DefaultDialogCard {
defaultItems.forEach { key -> defaultItems.forEach { key ->
val state = remember { var state by remember { mutableStateOf(toggledStates.contains(key)) }
mutableStateOf(toggledStates.contains(key))
}
fun toggle(value: Boolean? = null) { fun toggle(value: Boolean? = null) {
state.value = value ?: !state.value state = value ?: !state
if (state.value) { if (state) {
toggledStates.add(key) toggledStates.add(key)
} else { } else {
toggledStates.remove(key) toggledStates.remove(key)
@ -203,7 +203,7 @@ class Dialogs(
.weight(1f) .weight(1f)
) )
Switch( Switch(
checked = state.value, checked = state,
onCheckedChange = { onCheckedChange = {
toggle(it) toggle(it)
} }

View File

@ -44,9 +44,11 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -172,18 +174,16 @@ class FeaturesSection : Section() {
@Composable @Composable
private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
val showDialog = remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
val dialogComposable = remember { mutableStateOf<@Composable () -> Unit>({}) } var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
fun registerDialogOnClickCallback() = registerClickCallback { fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
showDialog.value = true
}
if (showDialog.value) { if (showDialog) {
Dialog( Dialog(
onDismissRequest = { showDialog.value = false } onDismissRequest = { showDialog = false }
) { ) {
dialogComposable.value() dialogComposable()
} }
} }
@ -203,12 +203,12 @@ class FeaturesSection : Section() {
when (val dataType = remember { property.key.dataType.type }) { when (val dataType = remember { property.key.dataType.type }) {
DataProcessors.Type.BOOLEAN -> { DataProcessors.Type.BOOLEAN -> {
val state = remember { mutableStateOf(propertyValue.get() as Boolean) } var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
Switch( Switch(
checked = state.value, checked = state,
onCheckedChange = registerClickCallback { onCheckedChange = registerClickCallback {
state.value = state.value.not() state = state.not()
propertyValue.setAny(state.value) propertyValue.setAny(state)
} }
) )
} }
@ -216,7 +216,7 @@ class FeaturesSection : Section() {
DataProcessors.Type.STRING_UNIQUE_SELECTION -> { DataProcessors.Type.STRING_UNIQUE_SELECTION -> {
registerDialogOnClickCallback() registerDialogOnClickCallback()
dialogComposable.value = { dialogComposable = {
dialogs.UniqueSelectionDialog(property) dialogs.UniqueSelectionDialog(property)
} }
@ -233,13 +233,13 @@ class FeaturesSection : Section() {
} }
DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
dialogComposable.value = { dialogComposable = {
when (dataType) { when (dataType) {
DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
dialogs.MultipleSelectionDialog(property) dialogs.MultipleSelectionDialog(property)
} }
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
dialogs.KeyboardInputDialog(property) { showDialog.value = false } dialogs.KeyboardInputDialog(property) { showDialog = false }
} }
else -> {} else -> {}
} }
@ -271,7 +271,7 @@ class FeaturesSection : Section() {
if (container.globalState == null) return if (container.globalState == null) return
val state = remember { mutableStateOf(container.globalState!!) } var state by remember { mutableStateOf(container.globalState!!) }
Box( Box(
modifier = Modifier modifier = Modifier
@ -288,10 +288,10 @@ class FeaturesSection : Section() {
} }
Switch( Switch(
checked = state.value, checked = state,
onCheckedChange = { onCheckedChange = {
state.value = state.value.not() state = state.not()
container.globalState = state.value container.globalState = state
} }
) )
} }
@ -301,7 +301,7 @@ class FeaturesSection : Section() {
@Composable @Composable
private fun PropertyCard(property: PropertyPair<*>) { private fun PropertyCard(property: PropertyPair<*>) {
val clickCallback = remember { mutableStateOf<ClickCallback?>(null) } var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -311,7 +311,7 @@ class FeaturesSection : Section() {
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable { .clickable {
clickCallback.value?.invoke(true) clickCallback?.invoke(true)
} }
.padding(all = 4.dp), .padding(all = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@ -371,7 +371,7 @@ class FeaturesSection : Section() {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
PropertyAction(property, registerClickCallback = { callback -> PropertyAction(property, registerClickCallback = { callback ->
clickCallback.value = callback clickCallback = callback
callback callback
}) })
} }
@ -381,20 +381,20 @@ class FeaturesSection : Section() {
@Composable @Composable
private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) {
val searchValue = remember { mutableStateOf("") } var searchValue by remember { mutableStateOf("") }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentSearchJob = remember { mutableStateOf<Job?>(null) } var currentSearchJob by remember { mutableStateOf<Job?>(null) }
rowScope.apply { rowScope.apply {
TextField( TextField(
value = searchValue.value, value = searchValue,
onValueChange = { keyword -> onValueChange = { keyword ->
searchValue.value = keyword searchValue = keyword
if (keyword.isEmpty()) { if (keyword.isEmpty()) {
navController.navigate(MAIN_ROUTE) navController.navigate(MAIN_ROUTE)
return@TextField return@TextField
} }
currentSearchJob.value?.cancel() currentSearchJob?.cancel()
scope.launch { scope.launch {
delay(300) delay(300)
navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder()
@ -402,7 +402,7 @@ class FeaturesSection : Section() {
.setPopUpTo(MAIN_ROUTE, false) .setPopUpTo(MAIN_ROUTE, false)
.build() .build()
) )
}.also { currentSearchJob.value = it } }.also { currentSearchJob = it }
}, },
keyboardActions = KeyboardActions(onDone = { keyboardActions = KeyboardActions(onDone = {
@ -428,10 +428,10 @@ class FeaturesSection : Section() {
@Composable @Composable
override fun TopBarActions(rowScope: RowScope) { override fun TopBarActions(rowScope: RowScope) {
val showSearchBar = remember { mutableStateOf(false) } var showSearchBar by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
if (showSearchBar.value) { if (showSearchBar) {
FeatureSearchBar(rowScope, focusRequester) FeatureSearchBar(rowScope, focusRequester)
LaunchedEffect(true) { LaunchedEffect(true) {
focusRequester.requestFocus() focusRequester.requestFocus()
@ -439,13 +439,13 @@ class FeaturesSection : Section() {
} }
IconButton(onClick = { IconButton(onClick = {
showSearchBar.value = showSearchBar.value.not() showSearchBar = showSearchBar.not()
if (!showSearchBar.value && navController.currentBackStackEntry?.destination?.route == SEARCH_FEATURE_ROUTE) { if (!showSearchBar && navController.currentBackStackEntry?.destination?.route == SEARCH_FEATURE_ROUTE) {
navController.navigate(MAIN_ROUTE) navController.navigate(MAIN_ROUTE)
} }
}) { }) {
Icon( Icon(
imageVector = if (showSearchBar.value) Icons.Filled.Close imageVector = if (showSearchBar) Icons.Filled.Close
else Icons.Filled.Search, else Icons.Filled.Search,
contentDescription = null contentDescription = null
) )

View File

@ -0,0 +1,123 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper
class AddFriendDialog(
private val context: RemoteSideContext,
private val section: SocialSection,
) {
@Composable
private fun ListCardEntry(name: String) {
Card(
modifier = Modifier.padding(5.dp),
) {
Text(text = name, modifier = Modifier.padding(10.dp))
}
}
@Composable
fun Content(dismiss: () -> Unit = { }) {
var cachedFriends by remember { mutableStateOf(null as List<MessagingFriendInfo>?) }
var cachedGroups by remember { mutableStateOf(null as List<MessagingGroupInfo>?) }
val coroutineScope = rememberCoroutineScope()
var timeoutJob: Job? = null
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.modDatabase.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends
cachedGroups = groups
timeoutJob?.cancel()
hasFetchError = false
}
SnapWidgetBroadcastReceiverHelper.create(BridgeClient.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
context.androidContext.sendBroadcast(it)
}.onFailure {
Logger.error("Failed to send broadcast", it)
hasFetchError = true
}
}
timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) {
delay(10000)
hasFetchError = true
}
}
}
Dialog(onDismissRequest = {
timeoutJob?.cancel()
dismiss()
}) {
if (hasFetchError) {
Text(text = "Failed to load friends and groups. Make sure Snapchat is installed and logged in.")
return@Dialog
}
if (cachedGroups == null || cachedFriends == null) {
CircularProgressIndicator(
modifier = Modifier
.padding()
.size(30.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimary
)
return@Dialog
}
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
item {
Text(text = "Groups", fontSize = 20.sp)
Spacer(modifier = Modifier.padding(5.dp))
}
items(cachedGroups!!.size) {
ListCardEntry(name = cachedGroups!![it].name)
}
item {
Text(text = "Friends", fontSize = 20.sp)
Spacer(modifier = Modifier.padding(5.dp))
}
items(cachedFriends!!.size) {
ListCardEntry(name = cachedFriends!![it].displayName ?: cachedFriends!![it].mutableUsername)
}
}
}
}
}

View File

@ -0,0 +1,4 @@
package me.rhunk.snapenhance.ui.manager.sections.social
class FriendTab {
}

View File

@ -0,0 +1,130 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.pagerTabIndicatorOffset
class SocialSection : Section() {
private lateinit var friendList: List<MessagingFriendInfo>
private lateinit var groupList: List<MessagingGroupInfo>
private val addFriendDialog by lazy {
AddFriendDialog(context, this)
}
override fun onResumed() {
friendList = context.modDatabase.getFriends()
groupList = context.modDatabase.getGroups()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
override fun Content() {
val titles = listOf("Friends", "Groups")
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var showAddFriendDialog by remember { mutableStateOf(false) }
if (showAddFriendDialog) {
addFriendDialog.Content {
showAddFriendDialog = false
}
}
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = {
showAddFriendDialog = true
},
modifier = Modifier.padding(10.dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null
)
}
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
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.padding(paddingValues), state = pagerState) { page ->
Column(
modifier = Modifier.fillMaxSize(),
) {
when (page) {
0 -> {
Text(text = "Friends")
Column {
friendList.forEach {
Text(text = it.displayName ?: it.mutableUsername)
}
}
}
1 -> {
Text(text = "Groups")
Column {
groupList.forEach {
Text(text = it.name)
}
}
}
}
}
}
}
}
}
}

View File

@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.alpha import androidx.compose.ui.draw.alpha
@ -79,13 +80,13 @@ class SetupActivity : ComponentActivity() {
setContent { setContent {
val navController = rememberNavController() val navController = rememberNavController()
val canGoNext = remember { mutableStateOf(false) } var canGoNext by remember { mutableStateOf(false) }
fun nextScreen() { fun nextScreen() {
if (!canGoNext.value) return if (!canGoNext) return
requiredScreens.firstOrNull()?.onLeave() requiredScreens.firstOrNull()?.onLeave()
if (requiredScreens.size > 1) { if (requiredScreens.size > 1) {
canGoNext.value = false canGoNext = false
requiredScreens.removeFirst() requiredScreens.removeFirst()
navController.navigate(requiredScreens.first().route) navController.navigate(requiredScreens.first().route)
} else { } else {
@ -102,7 +103,7 @@ class SetupActivity : ComponentActivity() {
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val alpha: Float by animateFloatAsState(if (canGoNext.value) 1f else 0f, val alpha: Float by animateFloatAsState(if (canGoNext) 1f else 0f,
label = "NextButton" label = "NextButton"
) )
@ -114,7 +115,7 @@ class SetupActivity : ComponentActivity() {
.alpha(alpha) .alpha(alpha)
) { ) {
Icon( Icon(
imageVector = if (requiredScreens.size <= 1 && canGoNext.value) { imageVector = if (requiredScreens.size <= 1 && canGoNext) {
Icons.Default.Check Icons.Default.Check
} else { } else {
Icons.Default.ArrowForwardIos Icons.Default.ArrowForwardIos
@ -135,7 +136,7 @@ class SetupActivity : ComponentActivity() {
startDestination = requiredScreens.first().route startDestination = requiredScreens.first().route
) { ) {
requiredScreens.forEach { screen -> requiredScreens.forEach { screen ->
screen.allowNext = { canGoNext.value = it } screen.allowNext = { canGoNext = it }
composable(screen.route) { composable(screen.route) {
BackHandler(true) {} BackHandler(true) {}
Column( Column(

View File

@ -11,9 +11,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@ -26,12 +28,12 @@ class MappingsScreen : SetupScreen() {
@Composable @Composable
override fun Content() { override fun Content() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val infoText = remember { mutableStateOf(null as String?) } var infoText by remember { mutableStateOf(null as String?) }
val isGenerating = remember { mutableStateOf(false) } var isGenerating by remember { mutableStateOf(false) }
if (infoText.value != null) { if (infoText != null) {
Dialog(onDismissRequest = { Dialog(onDismissRequest = {
infoText.value = null infoText = null
}) { }) {
Surface( Surface(
modifier = Modifier.padding(16.dp).fillMaxWidth(), modifier = Modifier.padding(16.dp).fillMaxWidth(),
@ -40,9 +42,9 @@ class MappingsScreen : SetupScreen() {
Column( Column(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) { ) {
Text(text = infoText.value!!) Text(text = infoText!!)
Button(onClick = { Button(onClick = {
infoText.value = null infoText = null
}, },
modifier = Modifier.padding(top = 5.dp).align(alignment = androidx.compose.ui.Alignment.End)) { modifier = Modifier.padding(top = 5.dp).align(alignment = androidx.compose.ui.Alignment.End)) {
Text(text = "OK") Text(text = "OK")
@ -63,27 +65,27 @@ class MappingsScreen : SetupScreen() {
} }
} }
val hasMappings = remember { mutableStateOf(false) } var hasMappings by remember { mutableStateOf(false) }
DialogText(text = context.translation["setup.mappings.dialog"]) DialogText(text = context.translation["setup.mappings.dialog"])
if (hasMappings.value) return if (hasMappings) return
Button(onClick = { Button(onClick = {
if (isGenerating.value) return@Button if (isGenerating) return@Button
isGenerating.value = true isGenerating = true
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
runCatching { runCatching {
tryToGenerateMappings() tryToGenerateMappings()
allowNext(true) allowNext(true)
infoText.value = context.translation["setup.mappings.generate_success"] infoText = context.translation["setup.mappings.generate_success"]
hasMappings.value = true hasMappings = true
}.onFailure { }.onFailure {
isGenerating.value = false isGenerating = false
infoText.value = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message infoText = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message
Logger.error("Failed to generate mappings", it) Logger.error("Failed to generate mappings", it)
} }
} }
}) { }) {
if (isGenerating.value) { if (isGenerating) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding().size(30.dp), modifier = Modifier.padding().size(30.dp),
strokeWidth = 3.dp, strokeWidth = 3.dp,

View File

@ -15,8 +15,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -78,10 +80,10 @@ class PickLanguageScreen : SetupScreen(){
DialogText(text = context.translation["setup.dialogs.select_language"]) DialogText(text = context.translation["setup.dialogs.select_language"])
val isDialog = remember { mutableStateOf(false) } var isDialog by remember { mutableStateOf(false) }
if (isDialog.value) { if (isDialog) {
Dialog(onDismissRequest = { isDialog.value = false }) { Dialog(onDismissRequest = { isDialog = false }) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)
@ -98,7 +100,7 @@ class PickLanguageScreen : SetupScreen(){
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
selectedLocale.value = locale selectedLocale.value = locale
isDialog.value = false isDialog = false
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -121,7 +123,7 @@ class PickLanguageScreen : SetupScreen(){
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Button(onClick = { Button(onClick = {
isDialog.value = true isDialog = true
}) { }) {
Text(text = getLocaleDisplayName(selectedLocale.value), fontSize = 16.sp, Text(text = getLocaleDisplayName(selectedLocale.value), fontSize = 16.sp,
fontWeight = FontWeight.Normal) fontWeight = FontWeight.Normal)

View File

@ -9,9 +9,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ui.util.ObservableMutableState
import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.setup.screens.SetupScreen
import me.rhunk.snapenhance.ui.util.ChooseFolderHelper import me.rhunk.snapenhance.ui.util.ChooseFolderHelper
import me.rhunk.snapenhance.ui.util.ObservableMutableState
class SaveFolderScreen : SetupScreen() { class SaveFolderScreen : SetupScreen() {
private lateinit var saveFolder: ObservableMutableState<String> private lateinit var saveFolder: ObservableMutableState<String>

View File

@ -0,0 +1,56 @@
package me.rhunk.snapenhance.ui.util
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.TabPosition
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.lerp
//https://github.com/google/accompanist/blob/main/pager-indicators/src/main/java/com/google/accompanist/pager/PagerTab.kt#L78
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.pagerTabIndicatorOffset(
pagerState: PagerState,
tabPositions: List<TabPosition>,
pageIndexMapping: (Int) -> Int = { it },
): Modifier = layout { measurable, constraints ->
if (tabPositions.isEmpty()) {
// If there are no pages, nothing to show
layout(constraints.maxWidth, 0) {}
} else {
val currentPage = minOf(tabPositions.lastIndex, pageIndexMapping(pagerState.currentPage))
val currentTab = tabPositions[currentPage]
val previousTab = tabPositions.getOrNull(currentPage - 1)
val nextTab = tabPositions.getOrNull(currentPage + 1)
val fraction = pagerState.currentPageOffsetFraction
val indicatorWidth = if (fraction > 0 && nextTab != null) {
lerp(currentTab.width, nextTab.width, fraction).roundToPx()
} else if (fraction < 0 && previousTab != null) {
lerp(currentTab.width, previousTab.width, -fraction).roundToPx()
} else {
currentTab.width.roundToPx()
}
val indicatorOffset = if (fraction > 0 && nextTab != null) {
lerp(currentTab.left, nextTab.left, fraction).roundToPx()
} else if (fraction < 0 && previousTab != null) {
lerp(currentTab.left, previousTab.left, -fraction).roundToPx()
} else {
currentTab.left.roundToPx()
}
val placeable = measurable.measure(
Constraints(
minWidth = indicatorWidth,
maxWidth = indicatorWidth,
minHeight = 0,
maxHeight = constraints.maxHeight
)
)
layout(constraints.maxWidth, maxOf(placeable.height, constraints.minHeight)) {
placeable.placeRelative(
indicatorOffset,
maxOf(constraints.minHeight - placeable.height, 0)
)
}
}
}

View File

@ -2,58 +2,77 @@ package me.rhunk.snapenhance.bridge;
import java.util.List; import java.util.List;
import me.rhunk.snapenhance.bridge.DownloadCallback; import me.rhunk.snapenhance.bridge.DownloadCallback;
import me.rhunk.snapenhance.bridge.SyncCallback;
interface BridgeInterface { interface BridgeInterface {
/** /**
* Execute a file operation * Execute a file operation
*/ */
byte[] fileOperation(int action, int fileType, in @nullable byte[] content); byte[] fileOperation(int action, int fileType, in @nullable byte[] content);
/** /**
* Get the content of a logged message from the database * Get the content of a logged message from the database
* *
* @param conversationId the ID of the conversation * @param conversationId the ID of the conversation
* @return the content of the message * @return the content of the message
*/ */
long[] getLoggedMessageIds(String conversationId, int limit); long[] getLoggedMessageIds(String conversationId, int limit);
/** /**
* Get the content of a logged message from the database * Get the content of a logged message from the database
* *
* @param id the ID of the message logger message * @param id the ID of the message logger message
* @return the content of the message * @return the content of the message
*/ */
@nullable byte[] getMessageLoggerMessage(String conversationId, long id); @nullable byte[] getMessageLoggerMessage(String conversationId, long id);
/** /**
* Add a message to the message logger database * Add a message to the message logger database
* *
* @param id the ID of the message logger message * @param id the ID of the message logger message
* @param message the content of the message * @param message the content of the message
*/ */
void addMessageLoggerMessage(String conversationId, long id, in byte[] message); void addMessageLoggerMessage(String conversationId, long id, in byte[] message);
/** /**
* Delete a message from the message logger database * Delete a message from the message logger database
* *
* @param id the ID of the message logger message * @param id the ID of the message logger message
*/ */
void deleteMessageLoggerMessage(String conversationId, long id); void deleteMessageLoggerMessage(String conversationId, long id);
/** /**
* Clear the message logger database * Clear the message logger database
*/ */
void clearMessageLogger(); void clearMessageLogger();
/** /**
* Fetch the locales * Fetch the locales
* *
* @return the locale result * @return the locale result
*/ */
Map<String, String> fetchLocales(String userLocale); Map<String, String> fetchLocales(String userLocale);
/** /**
* Enqueue a download * Enqueue a download
*/ */
void enqueueDownload(in Intent intent, DownloadCallback callback); void enqueueDownload(in Intent intent, DownloadCallback callback);
/**
* Get rules for a given user or conversation
*/
List<String> getRules(String objectType, String uuid);
/**
* Sync groups and friends
*/
oneway void sync(SyncCallback callback);
/**
* Pass all groups and friends to be able to add them to the database
* @param groups serialized groups
* @param friends serialized friends
*/
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
} }

View File

@ -0,0 +1,17 @@
package me.rhunk.snapenhance.bridge;
interface SyncCallback {
/**
* Called when the friend data has been synced
* @param uuid The uuid of the friend to sync
* @return The serialized friend data
*/
String syncFriend(String uuid);
/**
* Called when the conversation data has been synced
* @param uuid The uuid of the conversation to sync
* @return The serialized conversation data
*/
String syncGroup(String uuid);
}

View File

@ -20,7 +20,7 @@
"downloads": "Downloads", "downloads": "Downloads",
"features": "Features", "features": "Features",
"home": "Home", "home": "Home",
"friends": "Friends", "social": "Social",
"plugins": "Plugins" "plugins": "Plugins"
}, },
"features": { "features": {

View File

@ -1,12 +1,15 @@
package me.rhunk.snapenhance package me.rhunk.snapenhance
import android.content.Intent
import me.rhunk.snapenhance.core.eventbus.events.impl.OnSnapInteractionEvent import me.rhunk.snapenhance.core.eventbus.events.impl.OnSnapInteractionEvent
import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.core.eventbus.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.data.wrapper.impl.MessageContent import me.rhunk.snapenhance.data.wrapper.impl.MessageContent
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.manager.Manager import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper
class EventDispatcher( class EventDispatcher(
private val context: ModContext private val context: ModContext
@ -14,7 +17,7 @@ class EventDispatcher(
override fun init() { override fun init() {
context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param -> context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param ->
val messageContent = MessageContent(param.arg(1)) val messageContent = MessageContent(param.arg(1))
context.event.post(SendMessageWithContentEvent(messageContent).apply { adapter = param })?.let { context.event.post(SendMessageWithContentEvent(messageContent).apply { adapter = param })?.also {
if (it.canceled) { if (it.canceled) {
param.setResult(null) param.setResult(null)
} }
@ -29,7 +32,26 @@ class EventDispatcher(
conversationId = conversationId, conversationId = conversationId,
messageId = messageId messageId = messageId
) )
)?.let { )?.also {
if (it.canceled) {
param.setResult(null)
}
}
}
context.androidContext.classLoader.loadClass(SnapWidgetBroadcastReceiverHelper.CLASS_NAME)
.hook("onReceive", HookStage.BEFORE) { param ->
val intent = param.arg(1) as? Intent ?: return@hook
if (!SnapWidgetBroadcastReceiverHelper.isIncomingIntentValid(intent)) return@hook
val action = intent.getStringExtra("action") ?: return@hook
context.event.post(
SnapWidgetBroadcastReceiveEvent(
androidContext = context.androidContext,
intent = intent,
action = action
)
)?.also {
if (it.canceled) { if (it.canceled) {
param.setResult(null) param.setResult(null)
} }

View File

@ -1,34 +0,0 @@
package me.rhunk.snapenhance
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.Settings
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.download.DownloadTaskManager
import kotlin.system.exitProcess
/**
* Used to store objects between activities and receivers
*/
object SharedContext {
lateinit var downloadTaskManager: DownloadTaskManager
lateinit var translation: LocaleWrapper
fun ensureInitialized(context: Context) {
if (!this::downloadTaskManager.isInitialized) {
downloadTaskManager = DownloadTaskManager().apply {
init(context)
}
}
if (!this::translation.isInitialized) {
translation = LocaleWrapper().apply {
loadFromContext(context)
}
}
//askForPermissions(context)
}
}

View File

@ -7,12 +7,16 @@ import android.content.pm.PackageManager
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.rhunk.snapenhance.bridge.BridgeClient import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.bridge.SyncCallback
import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.messaging.MessagingFriendInfo
import me.rhunk.snapenhance.core.messaging.MessagingGroupInfo
import me.rhunk.snapenhance.data.SnapClassCache import me.rhunk.snapenhance.data.SnapClassCache
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.util.getApplicationInfoCompat import me.rhunk.snapenhance.util.ktx.getApplicationInfoCompat
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTime import kotlin.time.measureTime
@ -104,6 +108,7 @@ class SnapEnhance {
//if mappings aren't loaded, we can't initialize features //if mappings aren't loaded, we can't initialize features
if (!mappings.isMappingsLoaded()) return if (!mappings.isMappingsLoaded()) return
features.init() features.init()
syncRemote()
} }
}.also { time -> }.also { time ->
Logger.debug("init took $time") Logger.debug("init took $time")
@ -121,4 +126,53 @@ class SnapEnhance {
Logger.debug("onActivityCreate took $time") Logger.debug("onActivityCreate took $time")
} }
} }
private fun syncRemote() {
val database = appContext.database
appContext.bridgeClient.sync(object : SyncCallback.Stub() {
override fun syncFriend(uuid: String): String? {
return database.getFriendInfo(uuid)?.toJson()
}
override fun syncGroup(uuid: String): String? {
return database.getFeedEntryByConversationId(uuid)?.let {
MessagingGroupInfo(
it.key!!,
it.feedDisplayName!!,
it.participantsSize
).toJson()
}
}
})
appContext.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
if (event.action != BridgeClient.BRIDGE_SYNC_ACTION) return@subscribe
event.canceled = true
val feedEntries = appContext.database.getFeedEntries(Int.MAX_VALUE)
val groups = feedEntries.filter { it.friendUserId == null }.map {
MessagingGroupInfo(
it.key!!,
it.feedDisplayName!!,
it.participantsSize
)
}
val friends = feedEntries.filter { it.friendUserId != null }.map {
MessagingFriendInfo(
it.friendUserId!!,
it.friendDisplayName,
it.friendDisplayUsername!!.split("|")[1],
it.bitmojiAvatarId,
it.bitmojiSelfieId
)
}
appContext.bridgeClient.passGroupsAndFriends(
groups.map { it.toJson() },
friends.map { it.toJson() }
)
}
}
} }

View File

@ -17,7 +17,7 @@ import me.rhunk.snapenhance.action.AbstractAction
import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.database.objects.FriendFeedInfo import me.rhunk.snapenhance.database.objects.FriendFeedEntry
import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.CallbackBuilder
@ -108,8 +108,8 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
exportType = askExportType() ?: return@launch exportType = askExportType() ?: return@launch
mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null
val friendFeedEntries = context.database.getFriendFeed(20) val friendFeedEntries = context.database.getFeedEntries(20)
val selectedConversations = mutableListOf<FriendFeedInfo>() val selectedConversations = mutableListOf<FriendFeedEntry>()
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["chat_export.select_conversation"]) .setTitle(context.translation["chat_export.select_conversation"])
@ -182,12 +182,12 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
) )
} }
private suspend fun exportFullConversation(friendFeedInfo: FriendFeedInfo) { private suspend fun exportFullConversation(friendFeedEntry: FriendFeedEntry) {
//first fetch the first message //first fetch the first message
val conversationId = friendFeedInfo.key!! val conversationId = friendFeedEntry.key!!
val conversationName = friendFeedInfo.feedDisplayName ?: friendFeedInfo.friendDisplayName!!.split("|").lastOrNull() ?: "unknown" val conversationName = friendFeedEntry.feedDisplayName ?: friendFeedEntry.friendDisplayName!!.split("|").lastOrNull() ?: "unknown"
conversationAction(true, conversationId, if (friendFeedInfo.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE") conversationAction(true, conversationId, if (friendFeedEntry.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE")
logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName)) logDialog(context.translation.format("chat_export.exporting_message", "conversation" to conversationName))
@ -215,7 +215,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
logDialog(context.translation["chat_export.writing_output"]) logDialog(context.translation["chat_export.writing_output"])
MessageExporter( MessageExporter(
context = context, context = context,
friendFeedInfo = friendFeedInfo, friendFeedEntry = friendFeedEntry,
outputFile = outputFile, outputFile = outputFile,
mediaToDownload = mediaToDownload, mediaToDownload = mediaToDownload,
printLog = ::logDialog printLog = ::logDialog
@ -245,7 +245,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
} }
} }
private fun exportChatForConversations(conversations: List<FriendFeedInfo>) { private fun exportChatForConversations(conversations: List<FriendFeedEntry>) {
dialogLogs.clear() dialogLogs.clear()
val jobs = mutableListOf<Job>() val jobs = mutableListOf<Job>()

View File

@ -15,7 +15,10 @@ import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.types.BridgeFileType import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.types.FileActionType import me.rhunk.snapenhance.bridge.types.FileActionType
import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.messaging.MessagingRule
import me.rhunk.snapenhance.core.messaging.RuleScope
import me.rhunk.snapenhance.data.LocalePair import me.rhunk.snapenhance.data.LocalePair
import me.rhunk.snapenhance.util.SerializableDataObject
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -27,6 +30,10 @@ class BridgeClient(
private lateinit var future: CompletableFuture<Boolean> private lateinit var future: CompletableFuture<Boolean>
private lateinit var service: BridgeInterface private lateinit var service: BridgeInterface
companion object {
const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.bridge.SYNC"
}
fun start(callback: (Boolean) -> Unit) { fun start(callback: (Boolean) -> Unit) {
this.future = CompletableFuture() this.future = CompletableFuture()
@ -124,4 +131,14 @@ class BridgeClient(
} }
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = service.enqueueDownload(intent, callback) fun enqueueDownload(intent: Intent, callback: DownloadCallback) = service.enqueueDownload(intent, callback)
fun sync(callback: SyncCallback) = service.sync(callback)
fun passGroupsAndFriends(groups: List<String>, friends: List<String>) = service.passGroupsAndFriends(groups, friends)
fun getRulesFromId(type: RuleScope, targetUuid: String): List<MessagingRule> {
return service.getRules(type.name, targetUuid).map {
SerializableDataObject.fromJson(it, MessagingRule::class.java)
}.toList()
}
} }

View File

@ -31,7 +31,11 @@ class EventBus(
val obj = object : IListener<T> { val obj = object : IListener<T> {
override fun handle(event: T) { override fun handle(event: T) {
if (!filter(event)) return if (!filter(event)) return
listener(event) runCatching {
listener(event)
}.onFailure {
Logger.error("Error while handling event ${event::class.simpleName}", it)
}
} }
} }
subscribe(event, obj) subscribe(event, obj)

View File

@ -0,0 +1,11 @@
package me.rhunk.snapenhance.core.eventbus.events.impl
import android.content.Context
import android.content.Intent
import me.rhunk.snapenhance.core.eventbus.events.AbstractHookEvent
class SnapWidgetBroadcastReceiveEvent(
val androidContext: Context,
val intent: Intent?,
val action: String
) : AbstractHookEvent()

View File

@ -1,10 +0,0 @@
package me.rhunk.snapenhance.core.messaging
enum class EnumConversationFeature(
val value: String,
val objectType: ObjectType,
) {
DOWNLOAD("download", ObjectType.USER),
STEALTH("stealth", ObjectType.CONVERSATION),
AUTO_SAVE("auto_save", ObjectType.CONVERSATION);
}

View File

@ -1,28 +0,0 @@
package me.rhunk.snapenhance.core.messaging
enum class Mode {
BLACKLIST,
WHITELIST
}
enum class ObjectType {
USER,
CONVERSATION
}
data class FriendStreaks(
val userId: String,
val notify: Boolean,
val expirationTimestamp: Long,
val count: Int
)
data class MessagingRule(
val id: Int,
val objectType: ObjectType,
val targetUuid: String,
val enabled: Boolean,
val mode: Mode?,
val subject: String
)

View File

@ -0,0 +1,55 @@
package me.rhunk.snapenhance.core.messaging
import me.rhunk.snapenhance.util.SerializableDataObject
enum class Mode {
BLACKLIST,
WHITELIST
}
enum class RuleScope {
FRIEND,
GROUP
}
enum class ConversationFeature(
val value: String,
val ruleScope: RuleScope,
) {
DOWNLOAD("download", RuleScope.FRIEND),
STEALTH("stealth", RuleScope.GROUP),
AUTO_SAVE("auto_save", RuleScope.GROUP);
}
data class FriendStreaks(
val userId: String,
val notify: Boolean,
val expirationTimestamp: Long,
val count: Int
) : SerializableDataObject()
data class MessagingGroupInfo(
val conversationId: String,
val name: String,
val participantsCount: Int
) : SerializableDataObject()
data class MessagingFriendInfo(
val userId: String,
val displayName: String?,
val mutableUsername: String,
val bitmojiId: String?,
val selfieId: String?
) : SerializableDataObject()
data class MessagingRule(
val id: Int,
val ruleScope: RuleScope,
val targetUuid: String,
val enabled: Boolean,
val mode: Mode?,
val subject: String
) : SerializableDataObject()

View File

@ -2,7 +2,7 @@ package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.MessageState
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class Message(obj: Any?) : AbstractWrapper(obj) { class Message(obj: Any?) : AbstractWrapper(obj) {
val orderKey get() = instanceNonNull().getObjectField("mOrderKey") as Long val orderKey get() = instanceNonNull().getObjectField("mOrderKey") as Long

View File

@ -2,8 +2,8 @@ package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.setObjectField import me.rhunk.snapenhance.util.ktx.setObjectField
class MessageContent(obj: Any?) : AbstractWrapper(obj) { class MessageContent(obj: Any?) : AbstractWrapper(obj) {
var content var content

View File

@ -1,7 +1,7 @@
package me.rhunk.snapenhance.data.wrapper.impl package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) { class MessageDescriptor(obj: Any?) : AbstractWrapper(obj) {
val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long

View File

@ -1,8 +1,8 @@
package me.rhunk.snapenhance.data.wrapper.impl package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.setObjectField import me.rhunk.snapenhance.util.ktx.setObjectField
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
class MessageDestinations(obj: Any) : AbstractWrapper(obj){ class MessageDestinations(obj: Any) : AbstractWrapper(obj){

View File

@ -2,7 +2,7 @@ package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.PlayableSnapState import me.rhunk.snapenhance.data.PlayableSnapState
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ class MessageMetadata(obj: Any?) : AbstractWrapper(obj){
val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long val createdAt: Long get() = instanceNonNull().getObjectField("mCreatedAt") as Long

View File

@ -2,7 +2,7 @@ package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.SnapEnhance import me.rhunk.snapenhance.SnapEnhance
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.UUID import java.util.UUID

View File

@ -1,7 +1,7 @@
package me.rhunk.snapenhance.data.wrapper.impl package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) { class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) {
val userId = SnapUUID(instanceNonNull().getObjectField("mUserId")) val userId = SnapUUID(instanceNonNull().getObjectField("mUserId"))

View File

@ -2,7 +2,7 @@ package me.rhunk.snapenhance.data.wrapper.impl.media
import android.os.Parcelable import android.os.Parcelable
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import java.lang.reflect.Field import java.lang.reflect.Field

View File

@ -2,7 +2,7 @@ package me.rhunk.snapenhance.data.wrapper.impl.media.opera
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.ReflectionHelper import me.rhunk.snapenhance.util.ReflectionHelper
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import java.lang.reflect.Field import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap

View File

@ -74,10 +74,10 @@ class DatabaseAccess(private val context: ModContext) : Manager {
return obj return obj
} }
fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? { fun getFeedEntryByUserId(userId: String): FriendFeedEntry? {
return safeDatabaseOperation(openMain()) { database -> return safeDatabaseOperation(openMain()) { database ->
readDatabaseObject( readDatabaseObject(
FriendFeedInfo(), FriendFeedEntry(),
database, database,
"FriendsFeedView", "FriendsFeedView",
"friendUserId = ?", "friendUserId = ?",
@ -86,10 +86,10 @@ class DatabaseAccess(private val context: ModContext) : Manager {
} }
} }
fun getFriendFeedInfoByConversationId(conversationId: String): FriendFeedInfo? { fun getFeedEntryByConversationId(conversationId: String): FriendFeedEntry? {
return safeDatabaseOperation(openMain()) { return safeDatabaseOperation(openMain()) {
readDatabaseObject( readDatabaseObject(
FriendFeedInfo(), FriendFeedEntry(),
it, it,
"FriendsFeedView", "FriendsFeedView",
"key = ?", "key = ?",
@ -110,19 +110,19 @@ class DatabaseAccess(private val context: ModContext) : Manager {
} }
} }
fun getFriendFeed(limit: Int): List<FriendFeedInfo> { fun getFeedEntries(limit: Int): List<FriendFeedEntry> {
return safeDatabaseOperation(openMain()) { database -> return safeDatabaseOperation(openMain()) { database ->
val cursor = database.rawQuery( val cursor = database.rawQuery(
"SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?", "SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?",
arrayOf(limit.toString()) arrayOf(limit.toString())
) )
val list = mutableListOf<FriendFeedInfo>() val list = mutableListOf<FriendFeedEntry>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val friendFeedInfo = FriendFeedInfo() val friendFeedEntry = FriendFeedEntry()
try { try {
friendFeedInfo.write(cursor) friendFeedEntry.write(cursor)
} catch (_: Throwable) {} } catch (_: Throwable) {}
list.add(friendFeedInfo) list.add(friendFeedEntry)
} }
cursor.close() cursor.close()
list list

View File

@ -5,10 +5,10 @@ import android.database.Cursor
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getBlobOrNull import me.rhunk.snapenhance.util.ktx.getBlobOrNull
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getLong import me.rhunk.snapenhance.util.ktx.getLong
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.protobuf.ProtoReader
@Suppress("ArrayInDataClass") @Suppress("ArrayInDataClass")

View File

@ -3,21 +3,26 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.database.Cursor import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.getLong import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getLong
import me.rhunk.snapenhance.util.ktx.getStringOrNull
data class FriendFeedInfo( data class FriendFeedEntry(
var id: Int = 0, var id: Int = 0,
var feedDisplayName: String? = null, var feedDisplayName: String? = null,
var participantsSize: Int = 0, var participantsSize: Int = 0,
var lastInteractionTimestamp: Long = 0, var lastInteractionTimestamp: Long = 0,
var displayTimestamp: Long = 0, var displayTimestamp: Long = 0,
var displayInteractionType: String? = null, var displayInteractionType: String? = null,
var lastInteractionUserId: Int = 0, var lastInteractionUserId: Int? = null,
var key: String? = null, var key: String? = null,
var friendUserId: String? = null, var friendUserId: String? = null,
var friendDisplayName: String? = null, var friendDisplayName: String? = null,
var friendDisplayUsername: String? = null,
var friendLinkType: Int? = null,
var bitmojiAvatarId: String? = null,
var bitmojiSelfieId: String? = null,
) : DatabaseObject { ) : DatabaseObject {
@SuppressLint("Range") @SuppressLint("Range")
@ -29,10 +34,14 @@ data class FriendFeedInfo(
lastInteractionTimestamp = getLong("lastInteractionTimestamp") lastInteractionTimestamp = getLong("lastInteractionTimestamp")
displayTimestamp = getLong("displayTimestamp") displayTimestamp = getLong("displayTimestamp")
displayInteractionType = getStringOrNull("displayInteractionType") displayInteractionType = getStringOrNull("displayInteractionType")
lastInteractionUserId = getInteger("lastInteractionUserId") lastInteractionUserId = getIntOrNull("lastInteractionUserId")
key = getStringOrNull("key") key = getStringOrNull("key")
friendUserId = getStringOrNull("friendUserId") friendUserId = getStringOrNull("friendUserId")
friendDisplayName = getStringOrNull("friendDisplayUsername") friendDisplayName = getStringOrNull("friendDisplayName")
friendDisplayUsername = getStringOrNull("friendDisplayUsername")
friendLinkType = getIntOrNull("friendLinkType")
bitmojiAvatarId = getStringOrNull("bitmojiAvatarId")
bitmojiSelfieId = getStringOrNull("bitmojiSelfieId")
} }
} }
} }

View File

@ -3,9 +3,10 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.database.Cursor import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.SerializableDataObject
import me.rhunk.snapenhance.util.getLong import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getLong
import me.rhunk.snapenhance.util.ktx.getStringOrNull
data class FriendInfo( data class FriendInfo(
var id: Int = 0, var id: Int = 0,
@ -30,7 +31,7 @@ data class FriendInfo(
var isPinnedBestFriend: Int = 0, var isPinnedBestFriend: Int = 0,
var plusBadgeVisibility: Int = 0, var plusBadgeVisibility: Int = 0,
var usernameForSorting: String? = null var usernameForSorting: String? = null
) : DatabaseObject { ) : DatabaseObject, SerializableDataObject() {
@SuppressLint("Range") @SuppressLint("Range")
override fun write(cursor: Cursor) { override fun write(cursor: Cursor) {
with(cursor) { with(cursor) {

View File

@ -3,8 +3,8 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.database.Cursor import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
data class StoryEntry( data class StoryEntry(
var id: Int = 0, var id: Int = 0,

View File

@ -3,8 +3,8 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.database.Cursor import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
class UserConversationLink( class UserConversationLink(
var userId: String? = null, var userId: String? = null,

View File

@ -8,8 +8,8 @@ import me.rhunk.snapenhance.download.data.DownloadObject
import me.rhunk.snapenhance.download.data.DownloadStage import me.rhunk.snapenhance.download.data.DownloadStage
import me.rhunk.snapenhance.download.data.MediaFilter import me.rhunk.snapenhance.download.data.MediaFilter
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.getIntOrNull import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
class DownloadTaskManager { class DownloadTaskManager {
private lateinit var taskDatabase: SQLiteDatabase private lateinit var taskDatabase: SQLiteDatabase

View File

@ -5,7 +5,7 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.util.setObjectField import me.rhunk.snapenhance.util.ktx.setObjectField
class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) { class ConfigurationOverride : Feature("Configuration Override", loadParams = FeatureLoadParams.INIT_SYNC) {
override fun init() { override fun init() {

View File

@ -8,7 +8,7 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
lateinit var conversationManager: Any lateinit var conversationManager: Any

View File

@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.widget.ImageView import android.widget.ImageView
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
@ -22,6 +21,7 @@ import me.rhunk.snapenhance.download.DownloadManagerClient
import me.rhunk.snapenhance.download.data.DownloadMediaType import me.rhunk.snapenhance.download.data.DownloadMediaType
import me.rhunk.snapenhance.download.data.DownloadMetadata import me.rhunk.snapenhance.download.data.DownloadMetadata
import me.rhunk.snapenhance.download.data.InputMedia import me.rhunk.snapenhance.download.data.InputMedia
import me.rhunk.snapenhance.download.data.MediaFilter
import me.rhunk.snapenhance.download.data.SplitMediaAssetType import me.rhunk.snapenhance.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.download.data.toKeyPair import me.rhunk.snapenhance.download.data.toKeyPair
import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.Feature
@ -32,9 +32,8 @@ import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.ui.ViewAppearanceHelper import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.download.data.MediaFilter
import me.rhunk.snapenhance.util.download.RemoteMediaResolver import me.rhunk.snapenhance.util.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.BitmojiSelfie import me.rhunk.snapenhance.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.EncryptionHelper

View File

@ -4,7 +4,7 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hookConstructor import me.rhunk.snapenhance.hook.hookConstructor
import me.rhunk.snapenhance.util.setObjectField import me.rhunk.snapenhance.util.ktx.setObjectField
class UnlimitedMultiSnap : Feature("UnlimitedMultiSnap", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class UnlimitedMultiSnap : Feature("UnlimitedMultiSnap", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() { override fun asyncOnActivityCreate() {

View File

@ -4,7 +4,7 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() { override fun asyncOnActivityCreate() {

View File

@ -71,7 +71,7 @@ class MessageLogger : Feature("MessageLogger",
} }
measureTime { measureTime {
context.database.getFriendFeed(PREFETCH_FEED_COUNT).forEach { friendFeedInfo -> context.database.getFeedEntries(PREFETCH_FEED_COUNT).forEach { friendFeedInfo ->
fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList()) fetchedMessages.addAll(context.bridgeClient.getLoggedMessageIds(friendFeedInfo.key!!, PREFETCH_MESSAGE_COUNT).toList())
} }
}.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") } }.also { Logger.debug("Loaded ${fetchedMessages.size} cached messages in $it") }

View File

@ -12,7 +12,7 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import java.util.concurrent.Executors import java.util.concurrent.Executors
class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {

View File

@ -12,8 +12,8 @@ import android.os.Bundle
import android.os.UserHandle import android.os.UserHandle
import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.MediaReferenceType import me.rhunk.snapenhance.data.MediaReferenceType
import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.Message
@ -31,6 +31,7 @@ import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.util.snap.PreviewUtils import me.rhunk.snapenhance.util.snap.PreviewUtils
import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
companion object{ companion object{
@ -42,10 +43,6 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
private val cachedMessages = mutableMapOf<String, MutableList<String>>() // conversationId => cached messages private val cachedMessages = mutableMapOf<String, MutableList<String>>() // conversationId => cached messages
private val notificationIdMap = mutableMapOf<Int, String>() // notificationId => conversationId private val notificationIdMap = mutableMapOf<Int, String>() // notificationId => conversationId
private val broadcastReceiverClass by lazy {
context.androidContext.classLoader.loadClass("com.snap.widgets.core.BestFriendsWidgetProvider")
}
private val notifyAsUserMethod by lazy { private val notifyAsUserMethod by lazy {
XposedHelpers.findMethodExact( XposedHelpers.findMethodExact(
NotificationManager::class.java, "notifyAsUser", NotificationManager::class.java, "notifyAsUser",
@ -102,16 +99,18 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) { fun newAction(title: String, remoteAction: String, filter: (() -> Boolean), builder: (Notification.Action.Builder) -> Unit) {
if (!filter()) return if (!filter()) return
val intent = Intent().setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name)
.putExtra("conversation_id", conversationId) val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) {
.putExtra("notification_id", notificationData.id) putExtra("conversation_id", conversationId)
.putExtra("message_id", messageId) putExtra("notification_id", notificationData.id)
.setAction(remoteAction) putExtra("message_id", messageId)
}
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
context.androidContext, context.androidContext,
System.nanoTime().toInt(), System.nanoTime().toInt(),
intent, intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_MUTABLE
)).apply(builder).build() )).apply(builder).build()
actions.add(action) actions.add(action)
} }
@ -134,14 +133,12 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
} }
private fun setupBroadcastReceiverHook() { private fun setupBroadcastReceiverHook() {
Hooker.hook(broadcastReceiverClass, "onReceive", HookStage.BEFORE) { param -> context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
val androidContext = param.arg<Context>(0) val intent = event.intent ?: return@subscribe
val intent = param.arg<Intent>(1) val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe
val conversationId = intent.getStringExtra("conversation_id") ?: return@hook
val messageId = intent.getLongExtra("message_id", -1) val messageId = intent.getLongExtra("message_id", -1)
val notificationId = intent.getIntExtra("notification_id", -1) val notificationId = intent.getIntExtra("notification_id", -1)
val notificationManager = androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = event.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
notificationManager.activeNotifications.firstOrNull { it.id == id }?.let { notificationManager.activeNotifications.firstOrNull { it.id == id }?.let {
@ -152,7 +149,7 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
} }
} }
when (intent.action) { when (event.action) {
ACTION_REPLY -> { ACTION_REPLY -> {
val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input") val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input")
.toString() .toString()
@ -177,10 +174,10 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
context.longToast(it) context.longToast(it)
} }
} }
else -> return@hook else -> return@subscribe
} }
param.setResult(null) event.canceled = true
} }
} }

View File

@ -7,8 +7,8 @@ import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.hook.hookConstructor import me.rhunk.snapenhance.hook.hookConstructor
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.setObjectField import me.rhunk.snapenhance.util.ktx.setObjectField
class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PINNED_CONVERSATIONS, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PINNED_CONVERSATIONS, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() { override fun onActivityCreate() {

View File

@ -1,189 +0,0 @@
package me.rhunk.snapenhance.manager.impl
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import me.rhunk.snapmapper.Mapper
import me.rhunk.snapmapper.impl.BCryptClassMapper
import me.rhunk.snapmapper.impl.CallbackMapper
import me.rhunk.snapmapper.impl.CompositeConfigurationProviderMapper
import me.rhunk.snapmapper.impl.DefaultMediaItemMapper
import me.rhunk.snapmapper.impl.EnumMapper
import me.rhunk.snapmapper.impl.FriendsFeedEventDispatcherMapper
import me.rhunk.snapmapper.impl.MediaQualityLevelProviderMapper
import me.rhunk.snapmapper.impl.OperaPageViewControllerMapper
import me.rhunk.snapmapper.impl.PlatformAnalyticsCreatorMapper
import me.rhunk.snapmapper.impl.PlusSubscriptionMapper
import me.rhunk.snapmapper.impl.ScCameraSettingsMapper
import me.rhunk.snapmapper.impl.ScoreUpdateMapper
import me.rhunk.snapmapper.impl.StoryBoostStateMapper
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import kotlin.system.measureTimeMillis
@Suppress("UNCHECKED_CAST")
class MappingManager(private val context: ModContext) : Manager {
private val mappers = arrayOf(
BCryptClassMapper::class,
CallbackMapper::class,
DefaultMediaItemMapper::class,
MediaQualityLevelProviderMapper::class,
EnumMapper::class,
OperaPageViewControllerMapper::class,
PlatformAnalyticsCreatorMapper::class,
PlusSubscriptionMapper::class,
ScCameraSettingsMapper::class,
StoryBoostStateMapper::class,
FriendsFeedEventDispatcherMapper::class,
CompositeConfigurationProviderMapper::class,
ScoreUpdateMapper::class
)
private val mappings = ConcurrentHashMap<String, Any>()
val areMappingsLoaded: Boolean
get() = mappings.isNotEmpty()
private var snapBuildNumber = 0
@Suppress("deprecation")
override fun init() {
val currentBuildNumber = context.androidContext.packageManager.getPackageInfo(
Constants.SNAPCHAT_PACKAGE_NAME,
0
).longVersionCode.toInt()
snapBuildNumber = currentBuildNumber
if (context.bridgeClient.isFileExists(BridgeFileType.MAPPINGS)) {
runCatching {
loadCached()
}.onFailure {
context.crash("Failed to load cached mappings ${it.message}", it)
}
if (snapBuildNumber != currentBuildNumber) {
context.bridgeClient.deleteFile(BridgeFileType.MAPPINGS)
context.softRestartApp()
}
return
}
context.runOnUiThread {
val statusDialogBuilder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setMessage("Generating mappings, please wait...")
.setCancelable(false)
.setView(android.widget.ProgressBar(context.mainActivity).apply {
setPadding(0, 20, 0, 20)
})
val loadingDialog = statusDialogBuilder.show()
context.executeAsync {
runCatching {
refresh()
}.onSuccess {
context.shortToast("Generated mappings for build $snapBuildNumber")
context.softRestartApp()
}.onFailure {
Logger.error("Failed to generate mappings", it)
context.runOnUiThread {
loadingDialog.dismiss()
statusDialogBuilder.setView(null)
statusDialogBuilder.setMessage("Failed to generate mappings: $it")
statusDialogBuilder.setNegativeButton("Close") { _, _ ->
context.mainActivity!!.finish()
}
statusDialogBuilder.show()
}
}
}
}
}
private fun loadCached() {
if (!context.bridgeClient.isFileExists(BridgeFileType.MAPPINGS)) {
Logger.xposedLog("Mappings file does not exist")
return
}
val mappingsObject = JsonParser.parseString(
String(
context.bridgeClient.readFile(BridgeFileType.MAPPINGS),
StandardCharsets.UTF_8
)
).asJsonObject.also {
snapBuildNumber = it["snap_build_number"].asInt
}
mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> ->
if (value.isJsonArray) {
mappings[key] = context.gson.fromJson(value, ArrayList::class.java)
return@forEach
}
if (value.isJsonObject) {
mappings[key] = context.gson.fromJson(value, ConcurrentHashMap::class.java)
return@forEach
}
mappings[key] = value.asString
}
}
@Suppress("DEPRECATION")
private fun refresh() {
val mapper = Mapper(*mappers)
runCatching {
mapper.loadApk(context.androidContext.packageManager.getApplicationInfo(
Constants.SNAPCHAT_PACKAGE_NAME,
0
).sourceDir)
}.onFailure {
throw Exception("Failed to load APK", it)
}
measureTimeMillis {
val result = mapper.start().apply {
addProperty("snap_build_number", snapBuildNumber)
}
context.bridgeClient.writeFile(BridgeFileType.MAPPINGS, result.toString().toByteArray())
}.also {
Logger.xposedLog("Generated mappings in $it ms")
}
}
fun getMappedObject(key: String): Any {
if (mappings.containsKey(key)) {
return mappings[key]!!
}
throw Exception("No mapping found for $key")
}
fun getMappedObjectNullable(key: String): Any? {
return mappings[key]
}
fun getMappedClass(className: String): Class<*> {
return context.androidContext.classLoader.loadClass(getMappedObject(className) as String)
}
fun getMappedClass(key: String, subKey: String): Class<*> {
return context.androidContext.classLoader.loadClass(getMappedValue(key, subKey))
}
fun getMappedValue(key: String): String {
return getMappedObject(key) as String
}
fun <T : Any> getMappedList(key: String): List<T> {
return listOf(getMappedObject(key) as List<T>).flatten()
}
fun getMappedValue(key: String, subKey: String): String {
return getMappedMap(key)[subKey] as String
}
fun getMappedMap(key: String): Map<String, *> {
return getMappedObject(key) as Map<String, *>
}
}

View File

@ -204,7 +204,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
//mapped conversation fetch (may not work with legacy sc versions) //mapped conversation fetch (may not work with legacy sc versions)
messaging.lastFetchGroupConversationUUID?.let { messaging.lastFetchGroupConversationUUID?.let {
context.database.getFriendFeedInfoByConversationId(it.toString())?.let { friendFeedInfo -> context.database.getFeedEntryByConversationId(it.toString())?.let { friendFeedInfo ->
val participantSize = friendFeedInfo.participantsSize val participantSize = friendFeedInfo.participantsSize
return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null
} }
@ -280,7 +280,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
} }
run { run {
val userId = context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId ?: return@run val userId = context.database.getFeedEntryByConversationId(conversationId)?.friendUserId ?: return@run
if (friendFeedMenuOptions.contains("auto_download_blacklist")) { if (friendFeedMenuOptions.contains("auto_download_blacklist")) {
createToggleFeature(viewConsumer, createToggleFeature(viewConsumer,
"friend_menu_option.auto_download_blacklist", "friend_menu_option.auto_download_blacklist",
@ -340,7 +340,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
if (friendFeedMenuOptions.contains("auto_download_blacklist")) { if (friendFeedMenuOptions.contains("auto_download_blacklist")) {
run { run {
val userId = val userId =
context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId context.database.getFeedEntryByConversationId(conversationId)?.friendUserId
?: return@run ?: return@run
createActionButton( createActionButton(
"\u2B07\uFE0F", "\u2B07\uFE0F",

View File

@ -1,5 +0,0 @@
package me.rhunk.snapenhance.util
import android.content.Intent
typealias ActivityResultCallback = (requestCode: Int, resultCode: Int, data: Intent?) -> Unit

View File

@ -0,0 +1,22 @@
package me.rhunk.snapenhance.util
import com.google.gson.Gson
import com.google.gson.GsonBuilder
open class SerializableDataObject {
companion object {
val gson: Gson = GsonBuilder().create()
inline fun <reified T : SerializableDataObject> fromJson(json: String): T {
return gson.fromJson(json, T::class.java)
}
inline fun <reified T : SerializableDataObject> fromJson(json: String, type: Class<T>): T {
return gson.fromJson(json, type)
}
}
fun toJson(): String {
return gson.toJson(this)
}
}

View File

@ -18,9 +18,9 @@ import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.data.MediaReferenceType import me.rhunk.snapenhance.data.MediaReferenceType
import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.database.objects.FriendFeedInfo import me.rhunk.snapenhance.database.objects.FriendFeedEntry
import me.rhunk.snapenhance.database.objects.FriendInfo import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.util.getApplicationInfoCompat import me.rhunk.snapenhance.util.ktx.getApplicationInfoCompat
import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.EncryptionHelper import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
@ -50,7 +50,7 @@ enum class ExportFormat(
class MessageExporter( class MessageExporter(
private val context: ModContext, private val context: ModContext,
private val outputFile: File, private val outputFile: File,
private val friendFeedInfo: FriendFeedInfo, private val friendFeedEntry: FriendFeedEntry,
private val mediaToDownload: List<ContentType>? = null, private val mediaToDownload: List<ContentType>? = null,
private val printLog: (String) -> Unit = {}, private val printLog: (String) -> Unit = {},
) { ) {
@ -59,13 +59,13 @@ class MessageExporter(
fun readMessages(messages: List<Message>) { fun readMessages(messages: List<Message>) {
conversationParticipants = conversationParticipants =
context.database.getConversationParticipants(friendFeedInfo.key!!) context.database.getConversationParticipants(friendFeedEntry.key!!)
?.mapNotNull { ?.mapNotNull {
context.database.getFriendInfo(it) context.database.getFriendInfo(it)
}?.associateBy { it.userId!! } ?: emptyMap() }?.associateBy { it.userId!! } ?: emptyMap()
if (conversationParticipants.isEmpty()) if (conversationParticipants.isEmpty())
throw Throwable("Failed to get conversation participants for ${friendFeedInfo.key}") throw Throwable("Failed to get conversation participants for ${friendFeedEntry.key}")
this.messages = messages.sortedBy { it.orderKey } this.messages = messages.sortedBy { it.orderKey }
} }
@ -78,8 +78,8 @@ class MessageExporter(
private fun exportText(output: OutputStream) { private fun exportText(output: OutputStream) {
val writer = output.bufferedWriter() val writer = output.bufferedWriter()
writer.write("Conversation key: ${friendFeedInfo.key}\n") writer.write("Conversation key: ${friendFeedEntry.key}\n")
writer.write("Conversation Name: ${friendFeedInfo.feedDisplayName}\n") writer.write("Conversation Name: ${friendFeedEntry.feedDisplayName}\n")
writer.write("Participants:\n") writer.write("Participants:\n")
conversationParticipants.forEach { (userId, friendInfo) -> conversationParticipants.forEach { (userId, friendInfo) ->
writer.write(" $userId: ${friendInfo.displayName}\n") writer.write(" $userId: ${friendInfo.displayName}\n")
@ -233,8 +233,8 @@ class MessageExporter(
private fun exportJson(output: OutputStream) { private fun exportJson(output: OutputStream) {
val rootObject = JsonObject().apply { val rootObject = JsonObject().apply {
addProperty("conversationId", friendFeedInfo.key) addProperty("conversationId", friendFeedEntry.key)
addProperty("conversationName", friendFeedInfo.feedDisplayName) addProperty("conversationName", friendFeedEntry.feedDisplayName)
var index = 0 var index = 0
val participants = mutableMapOf<String, Int>() val participants = mutableMapOf<String, Int>()

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.util package me.rhunk.snapenhance.util.ktx
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.ApplicationInfoFlags

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.util package me.rhunk.snapenhance.util.ktx
import android.database.Cursor import android.database.Cursor

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.util package me.rhunk.snapenhance.util.ktx
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers

View File

@ -0,0 +1,24 @@
package me.rhunk.snapenhance.util.snap
import android.content.Intent
import me.rhunk.snapenhance.Constants
object SnapWidgetBroadcastReceiverHelper {
private const val ACTION_WIDGET_UPDATE = "com.snap.android.WIDGET_APP_START_UPDATE_ACTION"
const val CLASS_NAME = "com.snap.widgets.core.BestFriendsWidgetProvider"
fun create(targetAction: String, callback: Intent.() -> Unit): Intent {
with(Intent()) {
callback(this)
action = ACTION_WIDGET_UPDATE
putExtra(":)", true)
putExtra("action", targetAction)
setClassName(Constants.SNAPCHAT_PACKAGE_NAME, CLASS_NAME)
return this
}
}
fun isIncomingIntentValid(intent: Intent): Boolean {
return intent.action == ACTION_WIDGET_UPDATE && intent.getBooleanExtra(":)", false)
}
}