feat: friend notes

This commit is contained in:
rhunk
2024-11-08 18:28:28 +01:00
parent 54171cf772
commit 407974578d
17 changed files with 377 additions and 96 deletions

View File

@@ -74,7 +74,7 @@ class StreaksReminder(
notifyFriendList.forEach { (streaks, friend) ->
remoteSideContext.coroutineScope.launch {
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
val bitmojiImage = remoteSideContext.imageLoader.execute(
ImageRequestHelper.newBitmojiImageRequest(ctx, bitmojiUrl)
)

View File

@@ -195,6 +195,14 @@ class BridgeService : Service() {
)
}
override fun getScopeNotes(id: String): String? {
return remoteSideContext.database.getScopeNotes(id)
}
override fun setScopeNotes(id: String, content: String?) {
remoteSideContext.database.setScopeNotes(id, content)
}
override fun getScriptingInterface() = remoteSideContext.scriptManager
override fun getE2eeInterface() = remoteSideContext.e2eeImplementation

View File

@@ -102,6 +102,10 @@ class AppDatabase(
"repositories" to listOf(
"url VARCHAR PRIMARY KEY",
),
"notes" to listOf(
"id CHAR(36) PRIMARY KEY",
"content TEXT",
),
))
}
}

View File

@@ -0,0 +1,32 @@
package me.rhunk.snapenhance.storage
import androidx.core.database.getStringOrNull
fun AppDatabase.getScopeNotes(id: String): String? {
return database.rawQuery("SELECT content FROM notes WHERE id = ?", arrayOf(id)).use {
if (it.moveToNext()) {
it.getStringOrNull(0)
} else {
null
}
}
}
fun AppDatabase.setScopeNotes(id: String, content: String?) {
if (content == null || content.isEmpty() == true) {
executeAsync {
database.execSQL("DELETE FROM notes WHERE id = ?", arrayOf(id))
}
return
}
executeAsync {
database.execSQL("INSERT OR REPLACE INTO notes (id, content) VALUES (?, ?)", arrayOf(id, content))
}
}
fun AppDatabase.deleteScopeNotes(id: String) {
executeAsync {
database.execSQL("DELETE FROM notes WHERE id = ?", arrayOf(id))
}
}

View File

@@ -10,18 +10,22 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.rhunk.snapenhance.common.data.FriendStreaks
import me.rhunk.snapenhance.common.data.MessagingFriendInfo
import me.rhunk.snapenhance.common.data.MessagingGroupInfo
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.SocialScope
import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus
import me.rhunk.snapenhance.common.ui.EditNoteTextField
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList
import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie
@@ -89,6 +93,9 @@ class ManageScope: Routes.Route() {
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
var bottomComposable by remember {
mutableStateOf(null as (@Composable () -> Unit)?)
}
var hasScope by remember {
mutableStateOf(null as Boolean?)
}
@@ -103,7 +110,7 @@ class ManageScope: Routes.Route() {
}
}
friend?.let {
Friend(id, it, streaks)
Friend(id, it, streaks) { bottomComposable = it }
}
}
SocialScope.GROUP -> {
@@ -113,13 +120,17 @@ class ManageScope: Routes.Route() {
}
}
group?.let {
Group(it)
Group(it) { bottomComposable = it }
}
}
}
if (hasScope == true) {
if (context.config.root.experimental.friendNotes.get()) {
NotesCard(id)
}
RulesCard(id)
}
bottomComposable?.invoke()
if (hasScope == false) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -136,6 +147,33 @@ class ManageScope: Routes.Route() {
}
}
@Composable
private fun NotesCard(
id: String
) {
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
var scopeNotes by rememberAsyncMutableState(null) {
context.database.getScopeNotes(id)
}
AutoClearKeyboardFocus()
EditNoteTextField(
modifier = Modifier.padding(8.dp),
primaryColor = Color.White,
translation = context.translation,
content = scopeNotes,
setContent = { scopeNotes = it }
)
DisposableEffect(Unit) {
onDispose {
coroutineScope.launch {
context.database.setScopeNotes(id, scopeNotes)
}
}
}
}
@Composable
private fun RulesCard(
@@ -183,7 +221,7 @@ class ManageScope: Routes.Route() {
@Composable
private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Card(
ElevatedCard(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
@@ -244,26 +282,105 @@ class ManageScope: Routes.Route() {
private fun Friend(
id: String,
friend: MessagingFriendInfo,
streaks: FriendStreaks?
streaks: FriendStreaks?,
setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {}
) {
LaunchedEffect(Unit) {
setBottomComposable {
Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.e2eEncryption.globalState == true) {
SectionTitle(translation["e2ee_title"])
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
context.e2eeImplementation.friendKeyExists(friend.userId)
}
var importDialog by remember { mutableStateOf(false) }
if (importDialog) {
Dialog(
onDismissRequest = { importDialog = false }
) {
dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey ->
importDialog = false
runCatching {
val key = Base64.decode(newKey)
if (key.size != 32) {
context.longToast("Invalid key size (must be 32 bytes)")
return@runCatching
}
context.coroutineScope.launch {
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
}
hasSecretKey = true
}.onFailure {
context.longToast("Failed to import key: ${it.message}")
context.log.error("Failed to import key", it)
}
})
}
}
ContentCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}
}) {
Text(
text = "Export Base64",
maxLines = 1
)
}
}
OutlinedButton(onClick = { importDialog = true }) {
Text(
text = "Import Base64",
maxLines = 1
)
}
}
}
}
}
}
Column(
modifier = Modifier
.padding(10.dp)
.padding(5.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(
friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.THREE_D
friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
BitmojiImage(context = context, url = bitmojiUrl, size = 100)
Spacer(modifier = Modifier.height(16.dp))
BitmojiImage(context = context, url = bitmojiUrl, size = 120)
Text(
text = friend.displayName ?: friend.mutableUsername,
maxLines = 1,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(5.dp))
Text(
text = friend.mutableUsername,
maxLines = 1,
@@ -272,8 +389,6 @@ class ManageScope: Routes.Route() {
)
}
Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.storyLogger.get()) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -332,87 +447,14 @@ class ManageScope: Routes.Route() {
}
}
}
Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.e2eEncryption.globalState == true) {
SectionTitle(translation["e2ee_title"])
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
context.e2eeImplementation.friendKeyExists(friend.userId)
}
var importDialog by remember { mutableStateOf(false) }
if (importDialog) {
Dialog(
onDismissRequest = { importDialog = false }
) {
dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey ->
importDialog = false
runCatching {
val key = Base64.decode(newKey)
if (key.size != 32) {
context.longToast("Invalid key size (must be 32 bytes)")
return@runCatching
}
context.coroutineScope.launch {
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
}
hasSecretKey = true
}.onFailure {
context.longToast("Failed to import key: ${it.message}")
context.log.error("Failed to import key", it)
}
})
}
}
ContentCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}
}) {
Text(
text = "Export Base64",
maxLines = 1
)
}
}
OutlinedButton(onClick = { importDialog = true }) {
Text(
text = "Import Base64",
maxLines = 1
)
}
}
}
}
}
}
@Composable
private fun Group(group: MessagingGroupInfo) {
private fun Group(
group: MessagingGroupInfo,
setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {}
) {
Column(
modifier = Modifier
.padding(10.dp)
@@ -422,7 +464,6 @@ class ManageScope: Routes.Route() {
Text(
text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(5.dp))
Text(
text = translation.format(
"participants_text", "count" to group.participantsCount.toString()

View File

@@ -122,7 +122,7 @@ class SocialRootSection : Routes.Route() {
url = BitmojiSelfie.getBitmojiSelfie(
friend.selfieId,
friend.bitmojiId,
BitmojiSelfie.BitmojiSelfieType.THREE_D
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
)

View File

@@ -71,6 +71,10 @@ interface BridgeInterface {
*/
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
@nullable String getScopeNotes(String id);
oneway void setScopeNotes(String id, String content);
IScripting getScriptingInterface();
E2eeInterface getE2eeInterface();

View File

@@ -127,7 +127,8 @@
"streaks_expiration_text": "Expires in {eta}",
"streaks_expiration_text_expired": "Expired",
"reminder_button": "Set Reminder",
"delete_scope_confirm_dialog_title": "Are you sure you want to delete a {scope}?"
"delete_scope_confirm_dialog_title": "Are you sure you want to delete a {scope}?",
"notes_placeholder": "Click to add a note"
},
"logged_stories": {
"story_failed_to_load": "Failed to load",
@@ -1062,6 +1063,10 @@
"name": "Voice Note Auto Play",
"description": "Automatically plays the next voice note after the current one finishes"
},
"friend_notes": {
"name": "Friend Notes",
"description": "Allows you to add notes to friends profiles"
},
"cof_experiments": {
"name": "COF Experiments",
"description": "Enables unreleased/beta Snapchat features"

View File

@@ -70,6 +70,7 @@ class Experimental : ConfigContainer() {
val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() }
val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() }
val friendNotes = boolean("friend_notes") { requireRestart() }
val editMessage = boolean("edit_message") { requireRestart() }
val contextMenuFix = boolean("context_menu_fix") { requireRestart() }
val cofExperiments = multiple("cof_experiments", *cofExperimentList.toTypedArray()) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE); addNotices(FeatureNotice.UNSTABLE) }

View File

@@ -0,0 +1,47 @@
package me.rhunk.snapenhance.common.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
@Composable
fun EditNoteTextField(
modifier: Modifier = Modifier,
primaryColor: Color,
translation: LocaleWrapper,
content: String?,
setContent: (String) -> Unit
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 180.dp)
.then(modifier),
value = content ?: "",
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = primaryColor
),
onValueChange = {
setContent(it)
},
shape = MaterialTheme.shapes.medium,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp, color = primaryColor),
placeholder = { Text(text = translation["manager.sections.manage_scope.notes_placeholder"], fontSize = 12.sp) }
)
}

View File

@@ -31,8 +31,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
// https://github.com/tberghuis/FloatingCountdownTimer/blob/master/app/src/main/java/xyz/tberghuis/floatingtimer/service/overlayViewFactory.kt
fun createComposeView(context: Context, content: @Composable () -> Unit) = ComposeView(context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
fun createComposeView(
context: Context,
viewCompositionStrategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
content: @Composable () -> Unit
) = ComposeView(context).apply {
setViewCompositionStrategy(viewCompositionStrategy)
val lifecycleOwner = OverlayLifecycleOwner().apply {
performRestore(null)
handleLifecycleEvent(Lifecycle.Event.ON_CREATE)

View File

@@ -0,0 +1,47 @@
package me.rhunk.snapenhance.common.ui
import android.view.ViewTreeObserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.compose.runtime.getValue
@Composable
fun keyboardState(): State<Boolean> {
val keyboardState = remember { mutableStateOf(false) }
val localView = LocalView.current
val viewTreeObserver = localView.viewTreeObserver
DisposableEffect(viewTreeObserver) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
keyboardState.value = ViewCompat.getRootWindowInsets(localView)
?.isVisible(WindowInsetsCompat.Type.ime()) != false
}
viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose {
viewTreeObserver.takeIf { it.isAlive }?.removeOnGlobalLayoutListener(listener)
}
}
return keyboardState
}
@Composable
fun AutoClearKeyboardFocus(
onFocusClear: () -> Unit = {}
) {
val focusManager = LocalFocusManager.current
val keyboardState by keyboardState()
if (!keyboardState) {
onFocusClear()
focusManager.clearFocus()
}
}

View File

@@ -251,6 +251,10 @@ class BridgeClient(
service.setRule(targetUuid, type.key, state)
}
fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) }
fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) }
fun getScriptingInterface(): IScripting = safeServiceCall { service.scriptingInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }

View File

@@ -137,6 +137,7 @@ class FeatureManager(
DisableTelecomFramework(),
BetterTranscript(),
VoiceNoteOverride(),
FriendNotes(),
)
features.values.toList().forEach { feature ->

View File

@@ -79,7 +79,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
else UUID.randomUUID().toString()
).longHashCode().absoluteValue.toString(16)
val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.THREE_D)
val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
val downloadLogging by context.config.downloader.logging
if (downloadLogging.contains("started")) {

View File

@@ -0,0 +1,83 @@
package me.rhunk.snapenhance.core.features.impl.ui
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus
import me.rhunk.snapenhance.common.ui.EditNoteTextField
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.ui.getComposerContext
import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull
class FriendNotes: Feature("Friend Notes") {
override fun init() {
if (!context.config.experimental.friendNotes.get()) return
context.event.subscribe(AddViewEvent::class) { event ->
if (!event.viewClassName.endsWith("UnifiedProfileFlatlandProfileViewTopViewFrameLayout")) return@subscribe
val viewGroup = (event.view as? ViewGroup) ?: return@subscribe
viewGroup.post {
val composerRootView = viewGroup.getChildAt(0) ?: return@post
val composerContext = composerRootView.getComposerContext() ?: return@post
val userId = composerContext.viewModel?.getObjectFieldOrNull("_userId")?.toString() ?: return@post
if (userId == context.database.myUserId) return@post
viewGroup.removeView(composerRootView)
val manageNotesView = createComposeView(viewGroup.context, ViewCompositionStrategy.DisposeOnDetachedFromWindow) {
val primaryColor = remember { Color(this@FriendNotes.context.userInterface.colorPrimary) }
var isFetched by remember { mutableStateOf(false) }
var scopeNotes by rememberAsyncMutableState(null) {
this@FriendNotes.context.bridgeClient.getScopeNotes(userId).also {
isFetched = true
}
}
DisposableEffect(Unit) {
onDispose {
runCatching {
if (!isFetched) return@runCatching
context.bridgeClient.setScopeNotes(userId, scopeNotes)
}.onFailure {
context.log.error("Failed to save notes", it)
}
}
}
AutoClearKeyboardFocus()
EditNoteTextField(
modifier = Modifier.padding(top = 8.dp),
primaryColor = primaryColor,
translation = context.translation,
content = scopeNotes,
setContent = { scopeNotes = it }
)
}
val linearLayout = LinearLayout(viewGroup.context).apply {
orientation = LinearLayout.VERTICAL
addView(composerRootView)
addView(manageNotesView)
}
viewGroup.addView(linearLayout)
}
}
}
}

View File

@@ -89,7 +89,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
BitmojiSelfie.getBitmojiSelfie(
profile.bitmojiSelfieId.toString(),
profile.bitmojiAvatarId.toString(),
BitmojiSelfie.BitmojiSelfieType.THREE_D
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)!!
)
}