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.content.Intent
import android.os.IBinder
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.SharedContextHolder
import me.rhunk.snapenhance.bridge.types.BridgeFileType
import me.rhunk.snapenhance.bridge.types.FileActionType
import me.rhunk.snapenhance.bridge.wrapper.LocaleWrapper
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.util.SerializableDataObject
import kotlin.system.measureTimeMillis
class BridgeService : Service() {
private lateinit var messageLoggerWrapper: MessageLoggerWrapper
private lateinit var remoteSideContext: RemoteSideContext
private lateinit var syncCallback: SyncCallback
override fun onBind(intent: Intent): IBinder {
remoteSideContext = SharedContextHolder.remote(this).apply {
@ -25,28 +33,36 @@ class BridgeService : Service() {
inner class BridgeBinder : BridgeInterface.Stub() {
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]) {
FileActionType.CREATE_AND_READ -> {
resolvedFile?.let {
if (!it.exists()) {
return content?.also { content -> it.writeBytes(content) } ?: ByteArray(0)
return content?.also { content -> it.writeBytes(content) } ?: ByteArray(
0
)
}
it.readBytes()
} ?: ByteArray(0)
}
FileActionType.READ -> {
resolvedFile?.takeIf { it.exists() }?.readBytes() ?: ByteArray(0)
}
FileActionType.WRITE -> {
content?.also { resolvedFile?.writeBytes(content) } ?: ByteArray(0)
}
FileActionType.DELETE -> {
resolvedFile?.takeIf { it.exists() }?.delete()
ByteArray(0)
}
FileActionType.EXISTS -> {
if (resolvedFile?.exists() == true)
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) {
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 fetchLocales(userLocale: String) = LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate {
it.locale to it.content
}
override fun fetchLocales(userLocale: String) =
LocaleWrapper.fetchLocales(context = this@BridgeService, userLocale).associate {
it.locale to it.content
}
override fun enqueueDownload(intent: Intent, callback: DownloadCallback) {
DownloadProcessor(
remoteSideContext = SharedContextHolder.remote(this@BridgeService),
remoteSideContext = remoteSideContext,
callback = callback
).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
import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext
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.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.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.getInteger
import me.rhunk.snapenhance.util.getLongOrNull
import me.rhunk.snapenhance.util.getStringOrNull
import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.ktx.getLongOrNull
import me.rhunk.snapenhance.util.ktx.getStringOrNull
import java.util.concurrent.Executors
@ -20,19 +23,27 @@ class ModDatabase(
private val executor = Executors.newSingleThreadExecutor()
private lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
fun init() {
database = context.androidContext.openOrCreateDatabase("main.db", 0, null)
SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf(
"friends" to listOf(
"userId VARCHAR PRIMARY KEY",
"displayName VARCHAR",
"mutable_username VARCHAR",
"mutableUsername VARCHAR",
"bitmojiId VARCHAR",
"selfieId VARCHAR"
),
"groups" to listOf(
"conversationId VARCHAR PRIMARY KEY",
"name VARCHAR",
"participantsCount INTEGER"
),
"rules" to listOf(
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"objectType VARCHAR",
"scope VARCHAR",
"targetUuid VARCHAR",
"enabled BOOLEAN",
"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 {
try {
friends.forEach { friend ->
database.execSQL("INSERT OR REPLACE INTO friends VALUES (?, ?, ?, ?, ?)", arrayOf(
database.execSQL("INSERT OR REPLACE INTO groups 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.displayName,
friend.username,
friend.bitmojiAvatarId,
friend.bitmojiSelfieId
friend.streakExpirationTimestamp,
friend.streakLength
))
//sync streaks
if (friend.streakLength > 0) {
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))
}
} else {
database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(friend.userId))
}
} catch (e: Exception) {
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 ->
val rules = mutableListOf<MessagingRule>()
while (cursor.moveToNext()) {
rules.add(MessagingRule(
id = cursor.getInteger("id"),
objectType = ObjectType.valueOf(cursor.getStringOrNull("objectType")!!),
ruleScope = RuleScope.valueOf(cursor.getStringOrNull("scope")!!),
targetUuid = cursor.getStringOrNull("targetUuid")!!,
enabled = cursor.getInteger("enabled") == 1,
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.downloads.DownloadsSection
import me.rhunk.snapenhance.ui.manager.sections.features.FeaturesSection
import me.rhunk.snapenhance.ui.manager.sections.social.SocialSection
import kotlin.reflect.KClass
enum class EnumSection(
@ -39,9 +40,10 @@ enum class EnumSection(
icon = Icons.Filled.Home,
section = HomeSection::class
),
FRIENDS(
route = "friends",
icon = Icons.Filled.Group
SOCIAL(
route = "social",
icon = Icons.Filled.Group,
section = SocialSection::class
),
PLUGINS(
route = "plugins",

View File

@ -34,9 +34,11 @@ import androidx.compose.material3.RadioButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@ -102,15 +104,15 @@ class DownloadsSection : Section() {
@Composable
private fun FilterList() {
val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) }
IconButton(onClick = { showMenu.value = !showMenu.value}) {
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu}) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = null
)
}
DropdownMenu(expanded = showMenu.value, onDismissRequest = { showMenu.value = false }) {
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
MediaFilter.values().toList().forEach { filter ->
DropdownMenuItem(
text = {
@ -130,7 +132,7 @@ class DownloadsSection : Section() {
onClick = {
coroutineScope.launch {
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.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -72,14 +74,14 @@ class Dialogs(
add(0, "null")
}
val selectedValue = remember {
var selectedValue by remember {
mutableStateOf(property.value.getNullable()?.toString() ?: "null")
}
DefaultDialogCard {
keys.forEachIndexed { index, item ->
fun select() {
selectedValue.value = item
selectedValue = item
property.value.setAny(if (index == 0) {
null
} else {
@ -97,7 +99,7 @@ class Dialogs(
modifier = Modifier.weight(1f)
)
RadioButton(
selected = selectedValue.value == item,
selected = selectedValue == item,
onClick = { select() }
)
}
@ -179,13 +181,11 @@ class Dialogs(
val toggledStates = property.value.get() as MutableList<String>
DefaultDialogCard {
defaultItems.forEach { key ->
val state = remember {
mutableStateOf(toggledStates.contains(key))
}
var state by remember { mutableStateOf(toggledStates.contains(key)) }
fun toggle(value: Boolean? = null) {
state.value = value ?: !state.value
if (state.value) {
state = value ?: !state
if (state) {
toggledStates.add(key)
} else {
toggledStates.remove(key)
@ -203,7 +203,7 @@ class Dialogs(
.weight(1f)
)
Switch(
checked = state.value,
checked = state,
onCheckedChange = {
toggle(it)
}

View File

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

View File

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

View File

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

View File

@ -9,9 +9,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.util.ChooseFolderHelper
import me.rhunk.snapenhance.ui.util.ObservableMutableState
class SaveFolderScreen : SetupScreen() {
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 me.rhunk.snapenhance.bridge.DownloadCallback;
import me.rhunk.snapenhance.bridge.SyncCallback;
interface BridgeInterface {
/**
* Execute a file operation
*/
byte[] fileOperation(int action, int fileType, in @nullable byte[] content);
/**
* Execute a file operation
*/
byte[] fileOperation(int action, int fileType, in @nullable byte[] content);
/**
* Get the content of a logged message from the database
*
* @param conversationId the ID of the conversation
* @return the content of the message
*/
long[] getLoggedMessageIds(String conversationId, int limit);
/**
* Get the content of a logged message from the database
*
* @param conversationId the ID of the conversation
* @return the content of the message
*/
long[] getLoggedMessageIds(String conversationId, int limit);
/**
* Get the content of a logged message from the database
*
* @param id the ID of the message logger message
* @return the content of the message
*/
@nullable byte[] getMessageLoggerMessage(String conversationId, long id);
/**
* Get the content of a logged message from the database
*
* @param id the ID of the message logger message
* @return the content of the message
*/
@nullable byte[] getMessageLoggerMessage(String conversationId, long id);
/**
* Add a message to the message logger database
*
* @param id the ID of the message logger message
* @param message the content of the message
*/
void addMessageLoggerMessage(String conversationId, long id, in byte[] message);
/**
* Add a message to the message logger database
*
* @param id the ID of the message logger message
* @param message the content of the message
*/
void addMessageLoggerMessage(String conversationId, long id, in byte[] message);
/**
* Delete a message from the message logger database
*
* @param id the ID of the message logger message
*/
void deleteMessageLoggerMessage(String conversationId, long id);
/**
* Delete a message from the message logger database
*
* @param id the ID of the message logger message
*/
void deleteMessageLoggerMessage(String conversationId, long id);
/**
* Clear the message logger database
*/
void clearMessageLogger();
/**
* Clear the message logger database
*/
void clearMessageLogger();
/**
* Fetch the locales
*
* @return the locale result
*/
Map<String, String> fetchLocales(String userLocale);
/**
* Fetch the locales
*
* @return the locale result
*/
Map<String, String> fetchLocales(String userLocale);
/**
* Enqueue a download
*/
void enqueueDownload(in Intent intent, DownloadCallback callback);
/**
* Enqueue a download
*/
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",
"features": "Features",
"home": "Home",
"friends": "Friends",
"social": "Social",
"plugins": "Plugins"
},
"features": {

View File

@ -1,12 +1,15 @@
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.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.SnapUUID
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper
class EventDispatcher(
private val context: ModContext
@ -14,7 +17,7 @@ class EventDispatcher(
override fun init() {
context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param ->
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) {
param.setResult(null)
}
@ -29,7 +32,26 @@ class EventDispatcher(
conversationId = conversationId,
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) {
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.withContext
import me.rhunk.snapenhance.bridge.BridgeClient
import me.rhunk.snapenhance.bridge.SyncCallback
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.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
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.measureTime
@ -104,6 +108,7 @@ class SnapEnhance {
//if mappings aren't loaded, we can't initialize features
if (!mappings.isMappingsLoaded()) return
features.init()
syncRemote()
}
}.also { time ->
Logger.debug("init took $time")
@ -121,4 +126,53 @@ class SnapEnhance {
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.wrapper.impl.Message
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.ui.ViewAppearanceHelper
import me.rhunk.snapenhance.util.CallbackBuilder
@ -108,8 +108,8 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
exportType = askExportType() ?: return@launch
mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null
val friendFeedEntries = context.database.getFriendFeed(20)
val selectedConversations = mutableListOf<FriendFeedInfo>()
val friendFeedEntries = context.database.getFeedEntries(20)
val selectedConversations = mutableListOf<FriendFeedEntry>()
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.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
val conversationId = friendFeedInfo.key!!
val conversationName = friendFeedInfo.feedDisplayName ?: friendFeedInfo.friendDisplayName!!.split("|").lastOrNull() ?: "unknown"
val conversationId = friendFeedEntry.key!!
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))
@ -215,7 +215,7 @@ class ExportChatMessages : AbstractAction("action.export_chat_messages") {
logDialog(context.translation["chat_export.writing_output"])
MessageExporter(
context = context,
friendFeedInfo = friendFeedInfo,
friendFeedEntry = friendFeedEntry,
outputFile = outputFile,
mediaToDownload = mediaToDownload,
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()
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.FileActionType
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.util.SerializableDataObject
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.system.exitProcess
@ -27,6 +30,10 @@ class BridgeClient(
private lateinit var future: CompletableFuture<Boolean>
private lateinit var service: BridgeInterface
companion object {
const val BRIDGE_SYNC_ACTION = "me.rhunk.snapenhance.bridge.SYNC"
}
fun start(callback: (Boolean) -> Unit) {
this.future = CompletableFuture()
@ -124,4 +131,14 @@ class BridgeClient(
}
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> {
override fun handle(event: T) {
if (!filter(event)) return
listener(event)
runCatching {
listener(event)
}.onFailure {
Logger.error("Error while handling event ${event::class.simpleName}", it)
}
}
}
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.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
class Message(obj: Any?) : AbstractWrapper(obj) {
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.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.setObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.ktx.setObjectField
class MessageContent(obj: Any?) : AbstractWrapper(obj) {
var content

View File

@ -1,7 +1,7 @@
package me.rhunk.snapenhance.data.wrapper.impl
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) {
val messageId: Long get() = instanceNonNull().getObjectField("mMessageId") as Long

View File

@ -1,8 +1,8 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.setObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.ktx.setObjectField
@Suppress("UNCHECKED_CAST")
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.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
class MessageMetadata(obj: Any?) : AbstractWrapper(obj){
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.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import java.nio.ByteBuffer
import java.util.UUID

View File

@ -1,7 +1,7 @@
package me.rhunk.snapenhance.data.wrapper.impl
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) {
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 me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
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.util.ReflectionHelper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap

View File

@ -74,10 +74,10 @@ class DatabaseAccess(private val context: ModContext) : Manager {
return obj
}
fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? {
fun getFeedEntryByUserId(userId: String): FriendFeedEntry? {
return safeDatabaseOperation(openMain()) { database ->
readDatabaseObject(
FriendFeedInfo(),
FriendFeedEntry(),
database,
"FriendsFeedView",
"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()) {
readDatabaseObject(
FriendFeedInfo(),
FriendFeedEntry(),
it,
"FriendsFeedView",
"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 ->
val cursor = database.rawQuery(
"SELECT * FROM FriendsFeedView ORDER BY _id LIMIT ?",
arrayOf(limit.toString())
)
val list = mutableListOf<FriendFeedInfo>()
val list = mutableListOf<FriendFeedEntry>()
while (cursor.moveToNext()) {
val friendFeedInfo = FriendFeedInfo()
val friendFeedEntry = FriendFeedEntry()
try {
friendFeedInfo.write(cursor)
friendFeedEntry.write(cursor)
} catch (_: Throwable) {}
list.add(friendFeedInfo)
list.add(friendFeedEntry)
}
cursor.close()
list

View File

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

View File

@ -3,21 +3,26 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger
import me.rhunk.snapenhance.util.getLong
import me.rhunk.snapenhance.util.getStringOrNull
import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.ktx.getInteger
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 feedDisplayName: String? = null,
var participantsSize: Int = 0,
var lastInteractionTimestamp: Long = 0,
var displayTimestamp: Long = 0,
var displayInteractionType: String? = null,
var lastInteractionUserId: Int = 0,
var lastInteractionUserId: Int? = null,
var key: String? = null,
var friendUserId: String? = null,
var friendDisplayName: String? = null,
var friendDisplayUsername: String? = null,
var friendLinkType: Int? = null,
var bitmojiAvatarId: String? = null,
var bitmojiSelfieId: String? = null,
) : DatabaseObject {
@SuppressLint("Range")
@ -29,10 +34,14 @@ data class FriendFeedInfo(
lastInteractionTimestamp = getLong("lastInteractionTimestamp")
displayTimestamp = getLong("displayTimestamp")
displayInteractionType = getStringOrNull("displayInteractionType")
lastInteractionUserId = getInteger("lastInteractionUserId")
lastInteractionUserId = getIntOrNull("lastInteractionUserId")
key = getStringOrNull("key")
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.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger
import me.rhunk.snapenhance.util.getLong
import me.rhunk.snapenhance.util.getStringOrNull
import me.rhunk.snapenhance.util.SerializableDataObject
import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.ktx.getLong
import me.rhunk.snapenhance.util.ktx.getStringOrNull
data class FriendInfo(
var id: Int = 0,
@ -30,7 +31,7 @@ data class FriendInfo(
var isPinnedBestFriend: Int = 0,
var plusBadgeVisibility: Int = 0,
var usernameForSorting: String? = null
) : DatabaseObject {
) : DatabaseObject, SerializableDataObject() {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
with(cursor) {

View File

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

View File

@ -3,8 +3,8 @@ package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.getInteger
import me.rhunk.snapenhance.util.getStringOrNull
import me.rhunk.snapenhance.util.ktx.getInteger
import me.rhunk.snapenhance.util.ktx.getStringOrNull
class UserConversationLink(
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.MediaFilter
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.getIntOrNull
import me.rhunk.snapenhance.util.getStringOrNull
import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.ktx.getStringOrNull
class DownloadTaskManager {
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.hook.HookStage
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) {
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.Hooker
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) {
lateinit var conversationManager: Any

View File

@ -6,7 +6,6 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.widget.ImageView
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.Logger.xposedLog
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.DownloadMetadata
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.toKeyPair
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.Hooker
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.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
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.hook.HookStage
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) {
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.hook.HookStage
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) {
override fun asyncOnActivityCreate() {

View File

@ -71,7 +71,7 @@ class MessageLogger : Feature("MessageLogger",
}
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())
}
}.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.Hooker
import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import java.util.concurrent.Executors
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 de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.Constants
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.MediaReferenceType
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.MediaDownloaderHelper
import me.rhunk.snapenhance.util.snap.PreviewUtils
import me.rhunk.snapenhance.util.snap.SnapWidgetBroadcastReceiverHelper
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
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 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 {
XposedHelpers.findMethodExact(
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) {
if (!filter()) return
val intent = Intent().setClassName(Constants.SNAPCHAT_PACKAGE_NAME, broadcastReceiverClass.name)
.putExtra("conversation_id", conversationId)
.putExtra("notification_id", notificationData.id)
.putExtra("message_id", messageId)
.setAction(remoteAction)
val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) {
putExtra("conversation_id", conversationId)
putExtra("notification_id", notificationData.id)
putExtra("message_id", messageId)
}
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
context.androidContext,
System.nanoTime().toInt(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE
PendingIntent.FLAG_MUTABLE
)).apply(builder).build()
actions.add(action)
}
@ -134,14 +133,12 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
}
private fun setupBroadcastReceiverHook() {
Hooker.hook(broadcastReceiverClass, "onReceive", HookStage.BEFORE) { param ->
val androidContext = param.arg<Context>(0)
val intent = param.arg<Intent>(1)
val conversationId = intent.getStringExtra("conversation_id") ?: return@hook
context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
val intent = event.intent ?: return@subscribe
val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe
val messageId = intent.getLongExtra("message_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 ->
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 -> {
val input = RemoteInput.getResultsFromIntent(intent).getCharSequence("chat_reply_input")
.toString()
@ -177,10 +174,10 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
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.hook
import me.rhunk.snapenhance.hook.hookConstructor
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.setObjectField
import me.rhunk.snapenhance.util.ktx.getObjectField
import me.rhunk.snapenhance.util.ktx.setObjectField
class PinConversations : BridgeFileFeature("PinConversations", BridgeFileType.PINNED_CONVERSATIONS, loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
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)
messaging.lastFetchGroupConversationUUID?.let {
context.database.getFriendFeedInfoByConversationId(it.toString())?.let { friendFeedInfo ->
context.database.getFeedEntryByConversationId(it.toString())?.let { friendFeedInfo ->
val participantSize = friendFeedInfo.participantsSize
return it.toString() to if (participantSize == 1) focusedConversationTargetUser else null
}
@ -280,7 +280,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
}
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")) {
createToggleFeature(viewConsumer,
"friend_menu_option.auto_download_blacklist",
@ -340,7 +340,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
if (friendFeedMenuOptions.contains("auto_download_blacklist")) {
run {
val userId =
context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId
context.database.getFeedEntryByConversationId(conversationId)?.friendUserId
?: return@run
createActionButton(
"\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.wrapper.impl.Message
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.util.getApplicationInfoCompat
import me.rhunk.snapenhance.util.ktx.getApplicationInfoCompat
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
@ -50,7 +50,7 @@ enum class ExportFormat(
class MessageExporter(
private val context: ModContext,
private val outputFile: File,
private val friendFeedInfo: FriendFeedInfo,
private val friendFeedEntry: FriendFeedEntry,
private val mediaToDownload: List<ContentType>? = null,
private val printLog: (String) -> Unit = {},
) {
@ -59,13 +59,13 @@ class MessageExporter(
fun readMessages(messages: List<Message>) {
conversationParticipants =
context.database.getConversationParticipants(friendFeedInfo.key!!)
context.database.getConversationParticipants(friendFeedEntry.key!!)
?.mapNotNull {
context.database.getFriendInfo(it)
}?.associateBy { it.userId!! } ?: emptyMap()
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 }
}
@ -78,8 +78,8 @@ class MessageExporter(
private fun exportText(output: OutputStream) {
val writer = output.bufferedWriter()
writer.write("Conversation key: ${friendFeedInfo.key}\n")
writer.write("Conversation Name: ${friendFeedInfo.feedDisplayName}\n")
writer.write("Conversation key: ${friendFeedEntry.key}\n")
writer.write("Conversation Name: ${friendFeedEntry.feedDisplayName}\n")
writer.write("Participants:\n")
conversationParticipants.forEach { (userId, friendInfo) ->
writer.write(" $userId: ${friendInfo.displayName}\n")
@ -233,8 +233,8 @@ class MessageExporter(
private fun exportJson(output: OutputStream) {
val rootObject = JsonObject().apply {
addProperty("conversationId", friendFeedInfo.key)
addProperty("conversationName", friendFeedInfo.feedDisplayName)
addProperty("conversationId", friendFeedEntry.key)
addProperty("conversationName", friendFeedEntry.feedDisplayName)
var index = 0
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.ApplicationInfoFlags

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.util
package me.rhunk.snapenhance.util.ktx
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

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