commit 4dc78bcbe438b18131ed54c73868f95f42651e1a
parent 0fb25515408b82a89f8cbc137aa7419d7083bf1f
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Fri, 27 Sep 2024 22:23:05 +0200

refactor!: support resource obfuscation
There are still many bugs but it is stable enough for daily usage

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt | 25++++++++++++++++++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt | 24++++++++++++++++--------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt | 2+-
Mcommon/src/main/assets/lang/en_US.json | 8+++-----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt | 4+++-
Acommon/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt | 7+++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt | 33+++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt | 7++-----
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt | 6+++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt | 2++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt | 6++++--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt | 11++++-------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt | 7++-----
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt | 18++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 87+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt | 11+++++++----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt | 3+--
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt | 23++++++++++++++++++-----
Acore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt | 66------------------------------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt | 12++++++------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt | 37+++++++++++++++----------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt | 6+++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt | 12++++--------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt | 39+++++++++++++++++++++------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt | 8++------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt | 119++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Acore/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt | 129++++++++++++++++++++++++++++---------------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt | 2++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt | 81+++++--------------------------------------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt | 45++++++++++++++++++++++++++++++++++++---------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt | 266+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt | 108+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Dcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt | 78------------------------------------------------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt | 33+++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt | 15+++++++++++----
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt | 7++++++-
Mmapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt | 5++++-
Mnative/build.gradle.kts | 2+-
43 files changed, 894 insertions(+), 731 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/features/FeaturesRootSection.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -383,9 +384,17 @@ class FeaturesRootSection : Routes.Route() { FeatureNotice.REQUIRE_NATIVE_HOOKS.key to Color(0xFFFF5722), ) + val versionCheck = remember { property.key.params.versionCheck } + val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)} + val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true } + ElevatedCard( modifier = Modifier .fillMaxWidth() + .then( + if (isComponentDisabled) Modifier.graphicsLayer(alpha = 0.5f) + else Modifier + ) .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) ) { Row( @@ -433,7 +442,21 @@ class FeaturesRootSection : Routes.Route() { lineHeight = 15.sp ) } + + if (versionCheckPair != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = context.translation.format( + "manager.sections.features.${versionCheckPair.second.key}", + "version" to versionCheckPair.first.first + ), + color = Color(0xFFFF8585), + fontSize = 12.sp, + lineHeight = 15.sp + ) + } } + Row( modifier = Modifier .align(Alignment.CenterVertically) @@ -662,7 +685,7 @@ class FeaturesRootSection : Routes.Route() { contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp), verticalArrangement = Arrangement.Top ) { - items(properties) { + items(properties, key = { it.key.propertyName() }) { PropertyCard(it) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/setup/screens/impl/MappingsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import me.rhunk.snapenhance.ui.setup.screens.SetupScreen import me.rhunk.snapenhance.ui.util.AlertDialogs @@ -22,12 +21,14 @@ class MappingsScreen : SetupScreen() { var isGenerating by remember { mutableStateOf(false) } if (infoText != null) { - Dialog(onDismissRequest = { + fun dismiss() { infoText = null - }) { + goNext() + } + + Dialog(onDismissRequest = { dismiss() }) { remember { AlertDialogs(context.translation) }.InfoDialog(title = infoText!!) { - infoText = null - goNext() + dismiss() } } } @@ -40,10 +41,17 @@ class MappingsScreen : SetupScreen() { if (context.installationSummary.snapchatInfo == null) { throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"]) } - context.mappings.refresh() - withContext(Dispatchers.Main) { - goNext() + val warnings = context.mappings.refresh() + + if (warnings.isNotEmpty()) { + isGenerating = false + infoText = "${warnings.size} warning(s) occurred while generating mappings:\n\n${warnings.joinToString("\n")}".also { + context.log.warn(it) + } + return@launch } + + goNext() }.onFailure { isGenerating = false infoText = context.translation["setup.mappings.generate_failure"] + "\n\n" + it.message diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/util/AlertDialogs.kt @@ -139,7 +139,7 @@ class AlertDialogs( if (message != null) { Text( text = message, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 15.dp) ) } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -94,7 +94,9 @@ "config_import_success_toast": "Config imported successfully", "config_import_failure_toast": "Failed to import config {error}", "config_export_failure_toast": "Failed to export config {error}", - "saved_config_snackbar": "Config saved" + "saved_config_snackbar": "Config saved", + "older_required": "This feature requires Snapchat v{version} or older to work correctly", + "newer_required": "This feature requires Snapchat v{version} or newer to work correctly" }, "manage_rule_feature": { "disable_state_option": "Disabled", @@ -522,10 +524,6 @@ "name": "Disable Spotlight", "description": "Disables the Spotlight page" }, - "hide_settings_gear": { - "name": "Hide Settings Gear", - "description": "Hides the SnapEnhance Settings Gear in friend feed" - }, "friend_feed_menu_buttons": { "name": "Friend Feed Menu Buttons", "description": "Select which buttons to show in the Friend Feed Menu" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MappingsWrapper.kt @@ -67,7 +67,7 @@ class MappingsWrapper( isMappingsLoaded = true } - fun refresh() { + fun refresh(): List<String> { mappingUniqueHash = getUniqueBuildId() // reset native signature cache @@ -87,6 +87,8 @@ class MappingsWrapper( } writeBytes(result.toString().toByteArray()) } + + return classMapper.getWarns() } @Suppress("UNCHECKED_CAST") diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigConstants.kt @@ -0,0 +1,6 @@ +package me.rhunk.snapenhance.common.config + +/* + Due to recent resource obfuscation, some UI features will no longer work because it depends on non obfuscated resources +*/ +val RES_OBF_VERSION_CHECK = VersionCheck(maxVersion = ("13.7.0.42" to 157172))+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/ConfigObjects.kt @@ -35,6 +35,38 @@ enum class ConfigFlag { val id = 1 shl ordinal } +data class VersionCheck( + // Pair<versionString, versionCode> + val minVersion: Pair<String, Long>? = null, + val maxVersion: Pair<String, Long>? = null, + val isDisabled: Boolean = false, +) { + fun checkVersion(versionCode: Long): Pair<Pair<String, Long>, VersionRequirement>? { + minVersion?.let { + if (versionCode <= it.second) { + return minVersion to VersionRequirement.NEWER_REQUIRED + } + } + + maxVersion?.let { + if (versionCode >= it.second) { + return maxVersion to VersionRequirement.OLDER_REQUIRED + } + } + + return null + } +} + +enum class VersionRequirement( + val key: String +) { + OLDER_REQUIRED("older_required"), + NEWER_REQUIRED("newer_required"); + + val id = 1 shl ordinal +} + class ConfigParams( private var _flags: Int? = null, private var _notices: Int? = null, @@ -45,6 +77,7 @@ class ConfigParams( var customOptionTranslationPath: String? = null, var inputCheck: ((String) -> Boolean)? = { true }, var filenameFilter: ((String) -> Boolean)? = null, + var versionCheck: VersionCheck? = null, ) { val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Camera.kt @@ -4,10 +4,7 @@ import android.content.Context import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager import me.rhunk.snapenhance.common.Constants -import me.rhunk.snapenhance.common.config.ConfigContainer -import me.rhunk.snapenhance.common.config.ConfigFlag -import me.rhunk.snapenhance.common.config.FeatureNotice -import me.rhunk.snapenhance.common.config.PropertyValue +import me.rhunk.snapenhance.common.config.* import me.rhunk.snapenhance.common.logger.AbstractLogger class Camera : ConfigContainer() { @@ -51,7 +48,7 @@ class Camera : ConfigContainer() { } val disableCameras = multiple("disable_cameras", "front", "back") { addNotices(FeatureNotice.INTERNAL_BEHAVIOR); requireRestart() } - val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE) } + val immersiveCameraPreview = boolean("immersive_camera_preview") { addNotices(FeatureNotice.UNSTABLE); versionCheck = RES_OBF_VERSION_CHECK.copy(isDisabled = true) } val blackPhotos = boolean("black_photos") val frontCustomFrameRate = unique("front_custom_frame_rate", *customFrameRates) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) } val backCustomFrameRate = unique("back_custom_frame_rate", *customFrameRates) { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/UserInterfaceTweaks.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.common.config.impl import me.rhunk.snapenhance.common.config.ConfigContainer import me.rhunk.snapenhance.common.config.FeatureNotice +import me.rhunk.snapenhance.common.config.RES_OBF_VERSION_CHECK import me.rhunk.snapenhance.common.data.MessagingRuleType class UserInterfaceTweaks : ConfigContainer() { @@ -30,7 +31,7 @@ class UserInterfaceTweaks : ConfigContainer() { "material_you_light", "material_you_dark", "custom", - ) { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + ) { addNotices(FeatureNotice.UNSTABLE); requireRestart(); versionCheck = RES_OBF_VERSION_CHECK.copy(isDisabled = true) } val friendFeedMessagePreview = container("friend_feed_message_preview", FriendFeedMessagePreview()) { requireRestart() } val snapPreview = boolean("snap_preview") { addNotices(FeatureNotice.UNSTABLE); requireRestart() } val bootstrapOverride = container("bootstrap_override", BootstrapOverride()) { requireRestart() } @@ -52,11 +53,10 @@ class UserInterfaceTweaks : ConfigContainer() { "hide_billboard_prompt", "hide_snapchat_plus_gift_reminders", "hide_map_reactions", - ) { requireRestart() } + ) { requireRestart(); versionCheck = RES_OBF_VERSION_CHECK } val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } - val hideSettingsGear = boolean("hide_settings_gear") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() } val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -31,6 +31,7 @@ import me.rhunk.snapenhance.core.messaging.CoreMessagingBridge import me.rhunk.snapenhance.core.messaging.MessageSender import me.rhunk.snapenhance.core.scripting.CoreScriptRuntime import me.rhunk.snapenhance.core.ui.InAppOverlay +import me.rhunk.snapenhance.core.ui.UserInterface import me.rhunk.snapenhance.core.util.media.HttpServer import me.rhunk.snapenhance.nativelib.NativeConfig import me.rhunk.snapenhance.nativelib.NativeLib @@ -69,6 +70,7 @@ class ModContext( val scriptRuntime by lazy { CoreScriptRuntime(this, log) } val messagingBridge = CoreMessagingBridge(this) val inAppOverlay = InAppOverlay(this) + val userInterface = UserInterface(this) val isDeveloper by lazy { config.scripting.developerMode.get() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt @@ -162,6 +162,7 @@ class SnapEnhance { mappings.init(androidContext) database.init() eventDispatcher.init() + userInterface.init() //if mappings aren't loaded, we can't initialize features if (!mappings.isMappingsLoaded) return features.init() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/AddViewEvent.kt @@ -9,4 +9,6 @@ class AddViewEvent( var view: View, var index: Int, var layoutParams: ViewGroup.LayoutParams -) : AbstractHookEvent()- \ No newline at end of file +) : AbstractHookEvent() { + val viewClassName by lazy { view.javaClass.name } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/events/impl/BindViewEvent.kt @@ -1,19 +1,16 @@ package me.rhunk.snapenhance.core.event.events.impl import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.core.event.Event -import me.rhunk.snapenhance.core.util.ktx.getId class BindViewEvent( val prevModel: Any, val nextModel: Any?, var view: View ): Event() { - val chatMessageContentContainerId by lazy { - view.resources.getId("chat_message_content_container") - } - val databaseMessage by lazy { var message: ConversationMessage? = null chatMessage { _, messageId -> @@ -25,8 +22,8 @@ class BindViewEvent( inline fun chatMessage(block: (conversationId: String, messageId: String) -> Unit) { val modelToString = prevModel.toString() if (!modelToString.startsWith("ChatViewModel")) return - if (view.id != chatMessageContentContainerId) { - view = view.findViewById(chatMessageContentContainerId) ?: return + if (view !is LinearLayout) { + view = (view as ViewGroup).getChildAt(0) } modelToString.substringAfter("messageId=").substringBefore(",").split(":").apply { if (size != 3) return diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -60,6 +60,7 @@ class FeatureManager( fun init() { register( + Debug(), SecurityFeatures(), EndToEndEncryption(), ScopeSync(), @@ -102,7 +103,7 @@ class FeatureManager( HideStreakRestore(), HideFriendFeedEntry(), HideQuickAddFriendFeed(), - CallStartConfirmation(), + CallButtonsOverride(), SnapPreview(), BypassScreenshotDetection(), HalfSwipeNotifier(), @@ -142,8 +143,6 @@ class FeatureManager( runCatching { measureTimeMillis { feature.init() - }.also { - context.log.verbose("Feature ${feature.key} initialized in $it ms") } }.onFailure { context.log.error("Failed to init feature ${feature.key}", it) @@ -163,8 +162,6 @@ class FeatureManager( }.onFailure { context.log.error("Failed to run activity listener ${activityListener::class.simpleName}", it) } - }.also { - context.log.verbose("Activity listener ${activityListener::class.simpleName} executed in $it ms") } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Debug.kt @@ -0,0 +1,17 @@ +package me.rhunk.snapenhance.core.features.impl + +import android.widget.TextView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature + +class Debug : Feature("Debug") { + override fun init() { + if (!context.isDeveloper) return + context.event.subscribe(AddViewEvent::class) { event -> + event.view.post { + val viewText = event.view.takeIf { it is TextView }?.let { (it as TextView).text } ?: "" + event.view.contentDescription = "0x" + (event.view.id.takeIf { it > 0 }?.toString(16) ?: "") + " " + viewText + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -38,7 +38,6 @@ import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.ui.debugEditText -import me.rhunk.snapenhance.core.util.hook.HookAdapter import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getObjectField @@ -259,7 +258,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp forceAllowDuplicate: Boolean = false ) { //messages - paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || canAutoDownload("friend_snaps") }?.let { id -> + paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id -> val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() val conversationMessage = context.database.getConversationMessageFromId(messageId)!! @@ -294,7 +293,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //private stories paramMap["PLAYLIST_V2_GROUP"]?.takeIf { - forceDownload || canAutoDownload("friend_stories") + forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup -> val playlistGroupString = playlistGroup.toString() @@ -342,7 +341,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp //public stories if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && - (forceDownload || canAutoDownload("public_stories"))) { + (forceDownload || shouldAutoDownload("public_stories"))) { val author = ( paramMap["USER_ID"]?.let { context.database.getFriendInfo(it.toString())?.mutableUsername } // only for following users @@ -369,7 +368,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } //spotlight - if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { + if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { downloadOperaMedia(provideDownloadManagerClient( mediaIdentifier = paramMap["SNAP_ID"].toString(), downloadSource = MediaDownloadSource.SPOTLIGHT, @@ -484,7 +483,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } - private fun canAutoDownload(keyFilter: String? = null): Boolean { + private fun shouldAutoDownload(keyFilter: String? = null): Boolean { val options by context.config.downloader.autoDownloadSources return options.any { keyFilter == null || it.contains(keyFilter, true) } } @@ -492,48 +491,56 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp override fun init() { onNextActivityCreate { context.mappings.useMapper(OperaPageViewControllerMapper::class) { - val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> - val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() - if (viewState != "FULLY_DISPLAYED") { - return@onOperaViewStateCallback - } - val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> - val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) onOperaViewStateCallback@{ param -> + val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() - if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) - return@onOperaViewStateCallback + if (viewState != "FULLY_DISPLAYED") { + return@onOperaViewStateCallback + } - val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() - val isVideo = mediaParamMap.containsKey("video_media_info_list") + val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap - mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( - (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! - ) + if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) { + return@onOperaViewStateCallback + } - if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[SplitMediaAssetType.OVERLAY] = - MediaInfo(mediaParamMap["overlay_image_media_info"]!!) - } - lastSeenMapParams = mediaParamMap - lastSeenMediaInfoMap = mediaInfoMap + val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>() + val isVideo = mediaParamMap.containsKey("video_media_info_list") - if (!canAutoDownload()) return@onOperaViewStateCallback + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( + (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! + ) - context.executeAsync { - runCatching { - handleOperaMedia(mediaParamMap, mediaInfoMap, false) - }.onFailure { - context.log.error("Failed to handle opera media", it) - context.longToast(it.message) + if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { + mediaInfoMap[SplitMediaAssetType.OVERLAY] = + MediaInfo(mediaParamMap["overlay_image_media_info"]!!) } - } - } - arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> - classReference.get()?.hook( - methodName.get() ?: return@forEach, - HookStage.AFTER, onOperaViewStateCallback - ) + val shouldAutoDownload = shouldAutoDownload() + + if (shouldAutoDownload && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@onOperaViewStateCallback + + lastSeenMapParams = mediaParamMap + lastSeenMediaInfoMap = mediaInfoMap + + if (!shouldAutoDownload) { + return@onOperaViewStateCallback + } + + context.executeAsync { + runCatching { + handleOperaMedia(mediaParamMap, mediaInfoMap, false) + }.onFailure { + context.log.error("Failed to handle opera media", it) + context.longToast(it.message) + } + } + } } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/BetterLocation.kt @@ -25,6 +25,7 @@ import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.util.RandomWalking import me.rhunk.snapenhance.core.util.dataBuilder import me.rhunk.snapenhance.core.util.hook.HookStage @@ -209,7 +210,7 @@ class BetterLocation : Feature("Better Location") { hook("getLongitude", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(getLong()) } } - val mapFeaturesRootId = context.resources.getId("map_features_root") + val mapViewId = context.resources.getId("mapview") if (context.config.global.betterLocation.showBatteryLevel.get()) { findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param -> @@ -232,11 +233,13 @@ class BetterLocation : Feature("Better Location") { } context.event.subscribe(AddViewEvent::class) { event -> - if (event.view.id != mapFeaturesRootId) return@subscribe - val view = event.view as RelativeLayout + if (!event.viewClassName.endsWith("MapScreenRoot")) return@subscribe - view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { + val mapView = event.view.findViewById<View>(mapViewId) ?: throw IllegalStateException("Map view not found") + val view = (mapView.parent as ViewGroup).children().firstOrNull { it is RelativeLayout } as? RelativeLayout ?: throw IllegalStateException("Map view parent not found") + view.addView(createComposeView(view.context) { val darkTheme = remember { context.androidContext.isDarkTheme() } Box( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt @@ -35,7 +35,6 @@ import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper import me.rhunk.snapenhance.core.util.dataBuilder import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.ktx.getId import java.io.InputStream import java.lang.reflect.Method import kotlin.random.Random @@ -192,7 +191,7 @@ class MediaFilePicker : Feature("Media File Picker") { val buttonTag = Random.nextInt(0, 65535) context.event.subscribe(AddViewEvent::class) { event -> - if (event.parent.id != context.resources.getId("chat_drawer_container") || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe + if (event.parent !is FrameLayout || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/global/BypassVideoLengthRestriction.kt @@ -28,13 +28,26 @@ class BypassVideoLengthRestriction : fileObserver = (object : FileObserver(postedStorySnapFolder, MOVED_TO) { override fun onEvent(event: Int, path: String?) { - if (event != MOVED_TO || path?.endsWith("posted_story_snap.2") != true) return - fileObserver.stopWatching() + if (event != MOVED_TO || path?.contains("posted_story_snap.") != true) return - val file = File(postedStorySnapFolder, path) runCatching { - val fileContent = JsonParser.parseReader(file.reader()).asJsonObject - if (fileContent["timerOrDuration"].asLong < 0) file.delete() + val file = File(postedStorySnapFolder, path) + file.bufferedReader().use { bufferedReader -> + bufferedReader.mark(1) + if (bufferedReader.read() != 123) { + context.log.verbose("Ignoring non-JSON file: $path") + return@use + } + bufferedReader.reset() + val fileContent = JsonParser.parseReader(bufferedReader).asJsonObject + if ((fileContent["timerOrDuration"]?.also { + fileObserver.stopWatching() + }?.takeIf { !it.isJsonNull }?.asLong ?: 1) <= 0) { + context.log.verbose("Deleting $path") + file.delete() + } + } + }.onFailure { context.log.error("Failed to read story metadata file", it) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallButtonsOverride.kt @@ -0,0 +1,90 @@ +package me.rhunk.snapenhance.core.features.impl.messaging + +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent +import me.rhunk.snapenhance.core.features.Feature +import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.ui.children +import me.rhunk.snapenhance.core.ui.hideViewCompletely +import me.rhunk.snapenhance.core.util.hook.HookAdapter +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook + +class CallButtonsOverride : Feature("CallButtonsOverride") { + private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) { + if (motionEvent.action != MotionEvent.ACTION_UP) return + param.setResult(true) + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["call_start_confirmation.dialog_title"]) + .setMessage(context.translation["call_start_confirmation.dialog_message"]) + .setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() } + .setNeutralButton(context.translation["button.negative"]) { _, _ -> } + .show() + } + + override fun init() { + val hideUiComponents by context.config.userInterface.hideUiComponents + + val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons") + val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons") + val callStartConfirmation = context.config.messaging.callStartConfirmation.get() + + if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return + + var actionSheetVideoCallButtonId = -1 + var actionSheetAudioCallButtonId = -1 + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.viewClassName.endsWith("ConstraintLayout")) { + val layout = event.view as? ViewGroup ?: return@subscribe + val children = layout.children() + if (children.any { !it.javaClass.name.endsWith("FriendActionButton") } || children.size != 4) return@subscribe + + actionSheetVideoCallButtonId = children.getOrNull(2)?.id ?: throw IllegalStateException("Video call button not found") + actionSheetAudioCallButtonId = children.getOrNull(3)?.id ?: throw IllegalStateException("Audio call button not found") + + if (hideProfileCallButtons) { + children.getOrNull(2)?.hideViewCompletely() + children.getOrNull(3)?.hideViewCompletely() + } + } + + if (event.viewClassName.endsWith("CallButtonsView") && hideChatCallButtons) { + event.view.hideViewCompletely() + } + } + + onNextActivityCreate { + if (callStartConfirmation) { + findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param -> + val view = param.thisObject() as? ViewGroup ?: return@hook + if (!view.javaClass.name.endsWith("CallButtonsView")) return@hook + val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook + // check if the child composer view contains 2 call buttons + if (childComposerView.children().count { + it::class.java == childComposerView::class.java + } != 2) return@hook + hookTouchEvent(param, param.arg(0)) { + param.invokeOriginal() + } + } + + findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param -> + val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook + if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook + + hookTouchEvent(param, param.arg(0)) { + arrayOf( + MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0), + MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0) + ).forEach { + param.invokeOriginal(arrayOf(it)) + } + } + } + } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/CallStartConfirmation.kt @@ -1,65 +0,0 @@ -package me.rhunk.snapenhance.core.features.impl.messaging - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.core.ui.children -import me.rhunk.snapenhance.core.util.hook.HookAdapter -import me.rhunk.snapenhance.core.util.hook.HookStage -import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.ktx.getId - -class CallStartConfirmation : Feature("CallStartConfirmation") { - private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) { - if (motionEvent.action != MotionEvent.ACTION_UP) return - param.setResult(true) - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) - .setTitle(context.translation["call_start_confirmation.dialog_title"]) - .setMessage(context.translation["call_start_confirmation.dialog_message"]) - .setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() } - .setNeutralButton(context.translation["button.negative"]) { _, _ -> } - .show() - } - - @SuppressLint("DiscouragedApi") - override fun init() { - if (!context.config.messaging.callStartConfirmation.get()) return - - onNextActivityCreate { - val callButtonsStub = context.resources.getId("call_buttons_stub") - - findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param -> - val view = param.thisObject() as? ViewGroup ?: return@hook - if (view.id != callButtonsStub) return@hook - val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook - // check if the child composer view contains 2 call buttons - if (childComposerView.children().count { - it::class.java == childComposerView::class.java - } != 2) return@hook - hookTouchEvent(param, param.arg(0)) { - param.invokeOriginal() - } - } - - val callButton1 = context.resources.getId("friend_action_button3") - val callButton2 = context.resources.getId("friend_action_button4") - - findClass("com.snap.ui.view.stackdraw.StackDrawLayout").hook("onTouchEvent", HookStage.BEFORE) { param -> - val view = param.thisObject<View>() - if (view.id != callButton1 && view.id != callButton2) return@hook - - hookTouchEvent(param, param.arg(0)) { - arrayOf( - MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0), - MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0f, 0f, 0) - ).forEach { - param.invokeOriginal(arrayOf(it)) - } - } - } - } - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/ConversationToolbox.kt @@ -18,7 +18,9 @@ import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -35,7 +37,6 @@ import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.impl.messaging.Messaging -import me.rhunk.snapenhance.core.util.ktx.getId data class ComposableMenu( @@ -57,13 +58,12 @@ class ConversationToolbox : Feature("Conversation Toolbox") { @SuppressLint("SetTextI18n") override fun init() { onNextActivityCreate { - val defaultInputBarId = context.resources.getId("default_input_bar") - context.event.subscribe(AddViewEvent::class) { event -> - if (event.view.id != defaultInputBarId) return@subscribe if (composableList.isEmpty()) return@subscribe - (event.view as ViewGroup).addView(FrameLayout(event.view.context).apply { + val chatInputBar by getChatInputBar(event) ?: return@subscribe + + chatInputBar?.addView(FrameLayout(event.view.context).apply { layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, (52 * context.resources.displayMetrics.density).toInt(), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -8,7 +8,6 @@ import android.graphics.drawable.shapes.Shape import android.text.TextPaint import android.view.View import android.view.ViewGroup -import androidx.core.content.res.use import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -22,25 +21,15 @@ import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption import me.rhunk.snapenhance.core.ui.addForegroundDrawable import me.rhunk.snapenhance.core.ui.removeForegroundDrawable import me.rhunk.snapenhance.core.util.EvictingMap -import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getId -import me.rhunk.snapenhance.core.util.ktx.getIdentifier import me.rhunk.snapenhance.core.wrapper.impl.getMessageText import java.util.WeakHashMap import kotlin.math.absoluteValue class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { - private val endToEndEncryption by lazy { context.feature(EndToEndEncryption::class) } @OptIn(ExperimentalCoroutinesApi::class) private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(1) private val setting get() = context.config.userInterface.friendFeedMessagePreview - private val hasE2EE get() = context.config.experimental.e2eEncryption.globalState == true - - private val sigColorTextPrimary by lazy { - context.mainActivity!!.theme.obtainStyledAttributes( - intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) - ).use { it.getColor(0, 0) } - } private val cachedLayouts = WeakHashMap<String, View>() private val messageCache = EvictingMap<String, List<String>>(100) @@ -52,7 +41,7 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { message.messageContent ?.let { ProtoReader(it) } ?.followPath(4, 4)?.let { - if (hasE2EE) endToEndEncryption.decryptDatabaseMessage(message) else it + if (context.config.experimental.e2eEncryption.globalState == true) context.feature(EndToEndEncryption::class).decryptDatabaseMessage(message) else it } ?: return@mapNotNull null @@ -79,17 +68,21 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { onNextActivityCreate { val ffItemId = context.resources.getId("ff_item") - val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat() - val ffSdlAvatarMargin = context.resources.getDimens("ff_sdl_avatar_margin") - val ffSdlAvatarSize = context.resources.getDimens("ff_sdl_avatar_size") - val ffSdlPrimaryTextStartMargin = context.resources.getDimens("ff_sdl_primary_text_start_margin").toFloat() + val density = context.resources.displayMetrics.density + + val secondaryTextSize = 10 * density + val ffSdlAvatarMargin = (7 * density).toInt() + val ffSdlAvatarSize = (43 * density).toInt() + val ffSdlPrimaryTextStartMargin = 6 * density - val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * context.resources.displayMetrics.density).toInt() - val separatorHeight = (context.resources.displayMetrics.density * 2).toInt() - val avenirNextMedium = context.resources.getFont(context.resources.getIdentifier("avenir_next_medium", "font")) + val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt() + val separatorHeight = (density * 2).toInt() val textPaint = TextPaint().apply { textSize = secondaryTextSize - typeface = avenirNextMedium + } + + val typeface by lazy { + context.userInterface.avenirNextTypeface } context.event.subscribe(BuildMessageEvent::class) { param -> @@ -138,8 +131,8 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { override fun draw(canvas: Canvas, paint: Paint) { val offsetY = canvas.height.toFloat() - previewContainerHeight paint.textSize = secondaryTextSize - paint.color = sigColorTextPrimary - paint.typeface = avenirNextMedium + paint.color = context.userInterface.colorPrimary + paint.typeface = typeface messageCache[conversationId]?.forEachIndexed { index, messageString -> canvas.drawText(messageString, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/MessageIndicators.kt @@ -41,8 +41,8 @@ class MessageIndicators : Feature("Message Indicators") { context.event.subscribe(BindViewEvent::class) { event -> event.chatMessage { _, _ -> - val parentLinearLayout = event.view.parent as? ViewGroup ?: return@subscribe - parentLinearLayout.findViewWithTag<View>(messageInfoTag)?.let { parentLinearLayout.removeView(it) } + val view = event.view as? ViewGroup ?: return@subscribe + view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) } event.view.removeForegroundDrawable("messageIndicators") @@ -138,7 +138,7 @@ class MessageIndicators : Feature("Message Indicators") { LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) - parentLinearLayout.addView(this) + view.addView(this) } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SnapPreview.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.features.impl.ui -import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint @@ -15,7 +14,6 @@ import me.rhunk.snapenhance.core.ui.removeForegroundDrawable import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.media.PreviewUtils import me.rhunk.snapenhance.mapper.impl.CallbackMapper @@ -25,10 +23,8 @@ class SnapPreview : Feature("SnapPreview") { private val mediaFileCache = EvictingMap<String, File>(500) // mMediaId => mediaFile private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap - private val isEnabled get() = context.config.userInterface.snapPreview.get() - override fun init() { - if (!isEnabled) return + if (!context.config.userInterface.snapPreview.get()) return context.mappings.useMapper(CallbackMapper::class) { callbacks.getClass("ContentCallback")?.hook("handleContentResult", HookStage.BEFORE) { param -> val contentResult = param.arg<Any>(0) @@ -45,9 +41,9 @@ class SnapPreview : Feature("SnapPreview") { } onNextActivityCreate { - val chatMediaCardHeight = context.resources.getDimens("chat_media_card_height") - val chatMediaCardSnapMargin = context.resources.getDimens("chat_media_card_snap_margin") - val chatMediaCardSnapMarginStartSdl = context.resources.getDimens("chat_media_card_snap_margin_start_sdl") + val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run { + Triple(dpToPx(60), dpToPx(10), dpToPx(15)) + } fun decodeMedia(file: File) = runCatching { bitmapCache.getOrPut(file.absolutePath) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/SpotlightCommentsUsername.kt @@ -1,6 +1,7 @@ package me.rhunk.snapenhance.core.features.impl.ui import android.annotation.SuppressLint +import android.view.ViewGroup import android.widget.TextView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -8,8 +9,8 @@ import kotlinx.coroutines.withContext import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.impl.messaging.Messaging +import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.util.EvictingMap -import me.rhunk.snapenhance.core.util.ktx.getId class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") { private val usernameCache = EvictingMap<String, String>(150) @@ -20,34 +21,36 @@ class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") { onNextActivityCreate(defer = true) { val messaging = context.feature(Messaging::class) - val commentsCreatorBadgeTimestampId = context.resources.getId("comments_creator_badge_timestamp") - context.event.subscribe(BindViewEvent::class) { event -> - val commentsCreatorBadgeTimestamp = event.view.findViewById<TextView>(commentsCreatorBadgeTimestampId) ?: return@subscribe - val posterUserId = event.prevModel.toString().takeIf { it.startsWith("Comment") } ?.substringAfter("posterUserId=")?.substringBefore(",")?.substringBefore(")") ?: return@subscribe + if (posterUserId == "null") return@subscribe + fun setUsername(username: String) { usernameCache[posterUserId] = username + val commentsCreatorBadgeTimestamp = (event.view as ViewGroup).children().filterIsInstance<TextView>() + .getOrNull(1) ?: return if (commentsCreatorBadgeTimestamp.text.contains(username)) return - commentsCreatorBadgeTimestamp.text = " (${username})" + commentsCreatorBadgeTimestamp.text.toString() + commentsCreatorBadgeTimestamp.text = " (${username})" + commentsCreatorBadgeTimestamp?.text.toString() } - usernameCache[posterUserId]?.let { - setUsername(it) - return@subscribe - } + event.view.post { + usernameCache[posterUserId]?.let { + setUsername(it) + return@post + } - context.coroutineScope.launch { - val username = runCatching { - messaging.fetchSnapchatterInfos(listOf(posterUserId)).firstOrNull() - }.onFailure { - context.log.error("Failed to fetch snapchatter info for user $posterUserId", it) - }.getOrNull()?.username ?: return@launch + context.coroutineScope.launch { + val username = runCatching { + messaging.fetchSnapchatterInfos(listOf(posterUserId)).firstOrNull() + }.onFailure { + context.log.error("Failed to fetch snapchatter info for user $posterUserId", it) + }.getOrNull()?.username ?: return@launch - withContext(Dispatchers.Main) { - setUsername(username) + withContext(Dispatchers.Main) { + setUsername(username) + } } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/StealthModeIndicator.kt @@ -52,11 +52,6 @@ class StealthModeIndicator : Feature("StealthModeIndicator") { if (!context.config.userInterface.stealthModeIndicator.get()) return onNextActivityCreate { - val secondaryTextSize = context.resources.getDimens("ff_feed_cell_secondary_text_size").toFloat() - val sigColorTextPrimary = context.mainActivity!!.obtainStyledAttributes( - intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) - ).use { it.getColor(0, 0) } - stealthMode.addStateListener { conversationId, state -> runCatching { listeners[conversationId]?.invoke(stealthMode.getRuleState()?.let { if (it == RuleState.BLACKLIST) !state else state } ?: state) @@ -71,8 +66,9 @@ class StealthModeIndicator : Feature("StealthModeIndicator") { if (!isStealth || !event.view.isAttachedToWindow) return event.view.addForegroundDrawable("stealthModeIndicator", ShapeDrawable(object : Shape() { override fun draw(canvas: Canvas, paint: Paint) { + val secondaryTextSize = context.userInterface.dpToPx(10).toFloat() paint.textSize = secondaryTextSize - paint.color = sigColorTextPrimary + paint.color = context.userInterface.colorPrimary canvas.drawText( "\uD83D\uDC7B", 0f, diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/UITweaks.kt @@ -3,20 +3,35 @@ package me.rhunk.snapenhance.core.features.impl.ui import android.content.res.Resources import android.util.Size import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.FrameLayout +import android.widget.LinearLayout import me.rhunk.snapenhance.common.util.ktx.findFieldsToString import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent -import me.rhunk.snapenhance.core.event.events.impl.LayoutInflateEvent import me.rhunk.snapenhance.core.features.Feature -import me.rhunk.snapenhance.core.ui.getComposerContext +import me.rhunk.snapenhance.core.ui.* import me.rhunk.snapenhance.core.util.dataBuilder import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.Hooker import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getIdentifier +fun getChatInputBar(event: AddViewEvent): Lazy<ViewGroup?>? { + if (!event.parent.javaClass.name.endsWith("ChatInputLayout") || !event.viewClassName.endsWith("ViewSwitcher")) return null + + return lazy { + // get the first linear layout in the view switcher + val firstLinearLayout = (event.view as ViewGroup).children() + .firstOrNull { it is LinearLayout } as? ViewGroup ?: return@lazy null + // get the first linear layout with at least 3 children + firstLinearLayout.children() + .firstOrNull { v -> v is LinearLayout && v.childCount > 2 } as? LinearLayout + ?: return@lazy null + } +} + class UITweaks : Feature("UITweaks") { private val identifierCache = mutableMapOf<String, Int>() @@ -57,28 +72,10 @@ class UITweaks : Feature("UITweaks") { val displayMetrics = context.resources.displayMetrics val deviceAspectRatio = displayMetrics.widthPixels.toFloat() / displayMetrics.heightPixels.toFloat() - val callButtonsStub = getId("call_buttons_stub", "id") - val callButton1 = getId("friend_action_button3", "id") - val callButton2 = getId("friend_action_button4", "id") - val chatNoteRecordButton = getId("chat_note_record_button", "id") val unreadHintButton = getId("unread_hint_button", "id") val friendCardFrame = getId("friend_card_frame", "id") - View::class.java.hook("setVisibility", HookStage.BEFORE) { methodParam -> - val viewId = (methodParam.thisObject() as View).id - if (viewId == callButton1 || viewId == callButton2) { - if (!hiddenElements.contains("hide_profile_call_buttons")) return@hook - methodParam.setArg(0, View.GONE) - } - } - - context.event.subscribe(LayoutInflateEvent::class) { event -> - if (event.layoutId == getId("chat_input_bar_sharing_drawer_button", "layout") && hiddenElements.contains("hide_live_location_share_button")) { - hideView(event.view ?: return@subscribe) - } - } - Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( HookStage.AFTER, { isImmersiveCamera } @@ -92,22 +89,24 @@ class UITweaks : Feature("UITweaks") { var friendCardFrameSize: Size? = null - val fourDp by lazy { - (4 * context.androidContext.resources.displayMetrics.density).toInt() - } - context.event.subscribe(BindViewEvent::class, { hideStorySuggestions.isNotEmpty() }) { event -> if (event.view is FrameLayout) { + fun removeView() { + event.view.layoutParams = event.view.layoutParams?.apply { + width = 0; height = 0 + } ?: return + } + val viewModelString = event.prevModel.toString() val isSuggestedFriend by lazy { viewModelString.startsWith("DFFriendSuggestionCardViewModel") } val isMyStory by lazy { viewModelString.let { it.startsWith("CircularItemViewModel") && it.contains("storyId=")} } - if ((hideStorySuggestions.contains("hide_friend_suggestions") && isSuggestedFriend) || - (hideStorySuggestions.contains("hide_my_stories") && isMyStory)) { - event.view.layoutParams.apply { - width = 0; height = 0 - if (this is MarginLayoutParams) setMargins(-fourDp, 0, -fourDp, 0) - } + if (hideStorySuggestions.contains("hide_friend_suggestions") && isSuggestedFriend) { + removeView() + return@subscribe + } + if (hideStorySuggestions.contains("hide_my_stories") && isMyStory) { + removeView() return@subscribe } } @@ -167,19 +166,59 @@ class UITweaks : Feature("UITweaks") { } } - if (event.parent.id == getId("map_reactions_layout", "id") && hiddenElements.contains("hide_map_reactions")) { - hideView(view) + if (event.parent.javaClass.name.endsWith("ConstraintLayout") && event.view is LinearLayout && hiddenElements.contains("hide_map_reactions")) { + val viewGroup = event.view as ViewGroup + val children = viewGroup.children() + + // hide image views in the reaction bar + if (children.takeIf { it.count() == 5 }?.all { it.javaClass.name.endsWith("SnapImageView") } == true) { + children.forEach { imageView -> + imageView.hideViewCompletely() + } + } + } + + if (event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && hiddenElements.contains("hide_post_to_story_buttons")) { + if (event.parent.childCount == 1) { + event.view.hideViewCompletely() + } + } + + if (viewId == getId("send_btn", "id") && hiddenElements.contains("hide_post_to_story_buttons")) { + // hide previous view + if (event.parent.childCount > 0) { + val lastChild = event.parent.getChildAt(event.parent.childCount - 1)?.takeIf { it is LinearLayout } ?: return@subscribe + context.log.verbose("Hiding post to story button") + lastChild.hideViewCompletely() + } } - if ( - ((viewId == getId("post_tool", "id") || viewId == getId("story_button", "id")) && hiddenElements.contains("hide_post_to_story_buttons")) || - (viewId == chatNoteRecordButton && hiddenElements.contains("hide_voice_record_button")) || - (viewId == getId("chat_input_bar_sticker", "id") && hiddenElements.contains("hide_stickers_button")) || - (viewId == getId("chat_input_bar_sharing_drawer_button", "id") && hiddenElements.contains("hide_live_location_share_button")) || - (viewId == callButtonsStub && hiddenElements.contains("hide_chat_call_buttons")) - ) { - hideView(view) + getChatInputBar(event)?.let { lazyChatInputBar -> + val chatInputBar by lazyChatInputBar + + if (hiddenElements.contains("hide_live_location_share_button")) { + chatInputBar?.onLayoutChange { + chatInputBar!!.children().lastOrNull { it.javaClass.name.endsWith("AppCompatImageButton") && runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null } + ?.hideViewCompletely() + } + } + + if (hiddenElements.contains("hide_stickers_button")) { + chatInputBar + ?.children() + ?.lastOrNull { layout -> + layout is FrameLayout && layout.children().all { + it.javaClass.name.endsWith("SnapImageView") + } + } + ?.hideViewCompletely() + } + } + + if (viewId == chatNoteRecordButton && hiddenElements.contains("hide_voice_record_button")) { + view.hideViewCompletely() } + if (viewId == unreadHintButton && hiddenElements.contains("hide_unread_chat_hint")) { event.canceled = true } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/UserInterface.kt @@ -0,0 +1,62 @@ +package me.rhunk.snapenhance.core.ui + +import android.content.res.Resources +import android.graphics.Typeface +import android.util.TypedValue +import android.view.Gravity +import android.widget.TextView +import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.isDarkTheme + +class UserInterface( + private val context: ModContext +) { + private val fontMap = mutableMapOf<Int, Int>() + + val colorPrimary get() = if (context.androidContext.isDarkTheme()) 0xfff5f5f5.toInt() else 0xff212121.toInt() + val actionSheetBackground get() = if (context.androidContext.isDarkTheme()) 0xff1e1e1e.toInt() else 0xffffffff.toInt() + + val avenirNextTypeface: Typeface by lazy { + fontMap[600]?.let { context.resources.getFont(it) } ?: throw IllegalStateException("Avenir Next not loaded") + } + + fun dpToPx(dp: Int): Int { + return (dp * context.resources.displayMetrics.density).toInt() + } + + @Suppress("unused") + fun pxToDp(px: Int): Int { + return (px / context.resources.displayMetrics.density).toInt() + } + + fun getFontResource(weight: Int): Int? { + return fontMap[weight] + } + + fun applyActionButtonTheme(view: TextView) { + view.apply { + setTextColor(colorPrimary) + typeface = avenirNextTypeface + setShadowLayer(0F, 0F, 0F, 0) + gravity = Gravity.CENTER_VERTICAL + isAllCaps = false + textSize = 16f + outlineProvider = null + setPadding(dpToPx(12), dpToPx(15), 0, dpToPx(15)) + setBackgroundColor(0) + } + } + + fun init() { + Resources::class.java.hook("getValue", HookStage.AFTER) { param -> + val typedValue = param.arg<TypedValue>(1) + val path = typedValue.string ?: return@hook + if (!path.startsWith("res/") || !path.endsWith(".ttf")) return@hook + + val typeface = context.resources.getFont(typedValue.resourceId) + fontMap.getOrPut(typeface.weight) { typedValue.resourceId } + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/ViewAppearanceHelper.kt @@ -3,34 +3,19 @@ package me.rhunk.snapenhance.core.ui import android.app.Activity import android.app.AlertDialog import android.content.Context -import android.content.res.ColorStateList import android.graphics.Canvas -import android.graphics.Color import android.graphics.Paint -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.StateListDrawable import android.graphics.drawable.shapes.Shape import android.os.SystemClock -import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.Switch -import android.widget.TextView -import androidx.core.content.res.use import me.rhunk.snapenhance.core.SnapEnhance -import me.rhunk.snapenhance.core.util.ktx.getDimens -import me.rhunk.snapenhance.core.util.ktx.getDimensFloat -import me.rhunk.snapenhance.core.util.ktx.getIdentifier import me.rhunk.snapenhance.core.wrapper.impl.composer.ComposerContext import me.rhunk.snapenhance.core.wrapper.impl.composer.ComposerViewNode -fun View.applyTheme(componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { - ViewAppearanceHelper.applyTheme(this, componentWidth, hasRadius, isAmoled) -} - private val foregroundDrawableListTag = randomTag() @Suppress("UNCHECKED_CAST") @@ -106,6 +91,51 @@ fun View.findParent(maxIteration: Int = Int.MAX_VALUE, predicate: (View) -> Bool } +data class LayoutChangeParams( + val view: View, + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + val oldLeft: Int, + val oldTop: Int, + val oldRight: Int, + val oldBottom: Int +) + +fun View.onLayoutChange(block: (LayoutChangeParams) -> Unit): View.OnLayoutChangeListener { + return View.OnLayoutChangeListener { view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> + block(LayoutChangeParams(view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom)) + }.also { addOnLayoutChangeListener (it) } +} + +fun View.onAttachChange(onAttach: (View.OnAttachStateChangeListener) -> Unit = {}, onDetach: (View.OnAttachStateChangeListener) -> Unit = {}): View.OnAttachStateChangeListener { + return object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + onAttach(this) + } + override fun onViewDetachedFromWindow(v: View) { + onDetach(this) + } + }.also { addOnAttachStateChangeListener(it) } +} + +fun View.hideViewCompletely() { + fun hide() { + isEnabled = false + visibility = View.GONE + setWillNotDraw(true) + + layoutParams = layoutParams?.apply { + width = 0 + height = 0 + } ?: return + } + hide() + post { hide() } + onLayoutChange { hide() } +} + fun View.getComposerViewNode(): ComposerViewNode? { if (!SnapEnhance.classCache.composerView.isInstance(this)) return null @@ -125,74 +155,5 @@ fun View.getComposerContext(): ComposerContext? { } object ViewAppearanceHelper { - private fun createRoundedBackground(color: Int, radius: Float, hasRadius: Boolean): Drawable { - if (!hasRadius) return ColorDrawable(color) - return ShapeDrawable().apply { - paint.color = color - shape = android.graphics.drawable.shapes.RoundRectShape( - floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius), - null, - null - ) - } - } - - fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false, isAmoled: Boolean = true) { - val resources = component.context.resources - val actionSheetCellHorizontalPadding = resources.getDimens("action_sheet_cell_horizontal_padding") - val v11ActionCellVerticalPadding = resources.getDimens("v11_action_cell_vertical_padding") - - val sigColorTextPrimary = component.context.theme.obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr")) - ).use { it.getColor(0, 0) } - val sigColorBackgroundSurface = component.context.theme.obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr")) - ).use { it.getColor(0, 0) } - - val actionSheetDefaultCellHeight = resources.getDimens("action_sheet_default_cell_height") - val actionSheetCornerRadius = resources.getDimensFloat("action_sheet_corner_radius") - val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font") - - (component as? TextView)?.apply { - setTextColor(sigColorTextPrimary) - setShadowLayer(0F, 0F, 0F, 0) - gravity = Gravity.CENTER_VERTICAL - componentWidth?.let { width = it} - isAllCaps = false - minimumHeight = actionSheetDefaultCellHeight - textSize = 16f - typeface = resources.getFont(snapchatFontResId) - outlineProvider = null - setPadding(actionSheetCellHorizontalPadding, v11ActionCellVerticalPadding, actionSheetCellHorizontalPadding, v11ActionCellVerticalPadding) - } - - if (isAmoled) { - component.background = StateListDrawable().apply { - addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, radius = actionSheetCornerRadius, hasRadius)) - addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, radius = actionSheetCornerRadius, hasRadius)) - } - } else { - component.setBackgroundColor(0x0) - } - - (component as? Switch)?.apply { - switchMinWidth = resources.getDimens("v11_switch_min_width") - trackTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#1d1d1d"), - Color.parseColor("#26bd49") - ) - ) - thumbTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#F5F5F5"), - Color.parseColor("#26bd49") - ) - ) - } - } - fun newAlertDialogBuilder(context: Context?) = AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/AbstractMenu.kt @@ -3,12 +3,14 @@ package me.rhunk.snapenhance.core.ui.menu import android.view.View import android.view.ViewGroup import me.rhunk.snapenhance.core.ModContext +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent abstract class AbstractMenu { lateinit var menuViewInjector: MenuViewInjector lateinit var context: ModContext open fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) {} + open fun onViewAdded(event: AddViewEvent) {} open fun init() {} } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/MenuViewInjector.kt @@ -1,17 +1,13 @@ package me.rhunk.snapenhance.core.ui.menu import android.annotation.SuppressLint -import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout -import android.widget.ScrollView import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.impl.COFOverride -import me.rhunk.snapenhance.core.features.impl.messaging.Messaging -import me.rhunk.snapenhance.core.ui.findParent import me.rhunk.snapenhance.core.ui.menu.impl.* import me.rhunk.snapenhance.core.util.ktx.getIdentifier import kotlin.reflect.KClass @@ -20,13 +16,12 @@ import kotlin.reflect.KClass class MenuViewInjector : Feature("MenuViewInjector") { private val menuMap by lazy { arrayOf( + SettingsMenu(), NewChatActionMenu(), OperaContextActionMenu(), OperaViewerIcons(), - SettingsGearInjector(), FriendFeedInfoMenu(), ChatActionMenu(), - SettingsMenu() ).associateBy { it.context = context it.menuViewInjector = this @@ -43,20 +38,14 @@ class MenuViewInjector : Feature("MenuViewInjector") { onNextActivityCreate(defer = true) { menuMap.forEach { it.value.init() } - val messaging = context.feature(Messaging::class) - - val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id") - val actionMenuTitle = context.resources.getIdentifier("action_menu_title", "id") - val actionMenu = context.resources.getIdentifier("action_menu", "id") - val componentsHolder = context.resources.getIdentifier("components_holder", "id") - val feedNewChat = context.resources.getIdentifier("feed_new_chat", "id") - val hovaNavMapIcon = context.resources.getIdentifier("hova_header_search_icon", "id") - val contextMenuButtonIconView = context.resources.getIdentifier("context_menu_button_icon_view", "id") val chatActionMenu = context.resources.getIdentifier("chat_action_menu", "id") - val hasV2ActionMenu = { context.feature(COFOverride::class).hasActionMenuV2 } context.event.subscribe(AddViewEvent::class) { event -> + menuMap.forEach { it.value.onViewAdded(event) } + } + + context.event.subscribe(AddViewEvent::class) { event -> val originalAddView: (View) -> Unit = { event.adapter.invokeOriginal(arrayOf(it, -1, FrameLayout.LayoutParams( @@ -68,30 +57,6 @@ class MenuViewInjector : Feature("MenuViewInjector") { val viewGroup: ViewGroup = event.parent val childView: View = event.view - menuMap[OperaContextActionMenu::class]!!.inject(viewGroup, childView, originalAddView) - - if (event.view.id == actionSheetItemsContainerLayoutId) { - event.view.post { - if (event.parent.findParent(4) { - it.findViewById<View>(actionMenuTitle) != null - } == null) return@post - - val views = mutableListOf<View>() - menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, event.view) { - views.add(it) - } - views.reversed().forEach { (event.view as ViewGroup).addView(it, 0) } - } - } - - if (childView.id == contextMenuButtonIconView) { - menuMap[OperaViewerIcons::class]!!.inject(viewGroup, childView, originalAddView) - } - - if (event.parent.id == componentsHolder && (childView.id == feedNewChat || childView.id == hovaNavMapIcon)) { - menuMap[SettingsGearInjector::class]!!.inject(viewGroup, childView, originalAddView) - return@subscribe - } if (viewGroup !is LinearLayout && childView.id == chatActionMenu && context.isDeveloper) { event.view = LinearLayout(childView.context).apply { @@ -113,42 +78,6 @@ class MenuViewInjector : Feature("MenuViewInjector") { menuMap[ChatActionMenu::class]!!.inject(viewGroup, childView, originalAddView) return@subscribe } - - if (viewGroup !is LinearLayout && childView.id == actionMenu && messaging.lastFocusedConversationType == 1) { - val injectedLayout = LinearLayout(childView.context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.BOTTOM - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - addView(childView) - } - - event.parent.post { - injectedLayout.addView(ScrollView(injectedLayout.context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - weight = 1f; - setMargins(0, 100, 0, 0) - } - - addView(LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - menuMap[FriendFeedInfoMenu::class]?.inject(event.parent, injectedLayout) { view -> - view.layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 5, 0, 5) - } - addView(view) - } - }) - }, 0) - } - - event.view = injectedLayout - } } } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -1,5 +1,9 @@ package me.rhunk.snapenhance.core.ui.menu.impl +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams @@ -11,31 +15,41 @@ import me.rhunk.snapenhance.core.features.impl.experiments.ConvertMessageLocally import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.ui.ViewTagState -import me.rhunk.snapenhance.core.ui.applyTheme import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent import me.rhunk.snapenhance.core.util.hook.HookStage import me.rhunk.snapenhance.core.util.hook.hook -import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress class ChatActionMenu : AbstractMenu() { private val viewTagState = ViewTagState() - private val defaultGap by lazy { context.resources.getDimens("default_gap") } - private val chatActionMenuItemMargin by lazy { context.resources.getDimens("chat_action_menu_item_margin") } - private val actionMenuItemHeight by lazy { context.resources.getDimens("action_menu_item_height") } + private val defaultGap by lazy { context.userInterface.dpToPx(8) } + private val chatActionMenuItemMargin by lazy { context.userInterface.dpToPx(15) } + private val actionMenuItemHeight by lazy { context.userInterface.dpToPx(45) } + + private fun createRoundedBackground(color: Int, radius: Float, hasRadius: Boolean): Drawable { + if (!hasRadius) return ColorDrawable(color) + return ShapeDrawable().apply { + paint.color = color + shape = android.graphics.drawable.shapes.RoundRectShape( + floatArrayOf(radius, radius, radius, radius, radius, radius, radius, radius), + null, + null + ) + } + } private fun createContainer(viewGroup: ViewGroup): LinearLayout { - val parent = viewGroup.parent.parent as ViewGroup - return LinearLayout(viewGroup.context).apply layout@{ orientation = LinearLayout.VERTICAL layoutParams = MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ).apply { - applyTheme(parent.width, true) + this@ChatActionMenu.context.userInterface.apply { + background = createRoundedBackground(actionSheetBackground, 16F, true) + } setMargins(chatActionMenuItemMargin, 0, chatActionMenuItemMargin, defaultGap) } } @@ -84,11 +98,24 @@ class ChatActionMenu : AbstractMenu() { } with(button) { - applyTheme(viewGroup.width, true) + this@ChatActionMenu.context.userInterface.apply { + background = createRoundedBackground(actionSheetBackground, 16F, true) + setTextColor(colorPrimary) + typeface = resources.getFont(getFontResource(600) ?: throw IllegalStateException("Avenir Next not loaded")) + } + isAllCaps = false + setShadowLayer(0F, 0F, 0F, 0) + setPadding(chatActionMenuItemMargin, 0, 0, 0) + + gravity = Gravity.CENTER_VERTICAL + layoutParams = MarginLayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ).apply { + post { + width = viewGroup.width + } height = actionMenuItemHeight + defaultGap } buttonContainer.addView(this) diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -5,9 +5,12 @@ import android.content.res.Resources import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable +import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.LinearLayout +import android.widget.ScrollView import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* @@ -33,7 +36,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.content.res.use import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -48,15 +50,16 @@ import me.rhunk.snapenhance.common.ui.createComposeAlertDialog import me.rhunk.snapenhance.common.ui.createComposeView import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.impl.experiments.EndToEndEncryption import me.rhunk.snapenhance.core.features.impl.messaging.AutoMarkAsRead import me.rhunk.snapenhance.core.features.impl.messaging.Messaging import me.rhunk.snapenhance.core.features.impl.spying.MessageLogger import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper -import me.rhunk.snapenhance.core.ui.applyTheme +import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.ui.randomTag import me.rhunk.snapenhance.core.ui.triggerRootCloseTouchEvent -import me.rhunk.snapenhance.core.util.ktx.getIdentifier import me.rhunk.snapenhance.core.util.ktx.isDarkTheme import java.net.HttpURLConnection import java.net.URL @@ -67,22 +70,6 @@ import java.util.Date import java.util.Locale class FriendFeedInfoMenu : AbstractMenu() { - private val avenirNextMediumFont by lazy { - FontFamily( - Font(context.resources.getIdentifier("avenir_next_medium", "font"), FontWeight.Medium) - ) - } - private val sigColorTextPrimary by lazy { - context.androidContext.theme.obtainStyledAttributes( - intArrayOf(context.resources.getIdentifier("sigColorTextPrimary", "attr")) - ).use { it.getColor(0, 0) } - } - private val sigColorBackgroundSurface by lazy { - context.androidContext.theme.obtainStyledAttributes( - intArrayOf(context.resources.getIdentifier("sigColorBackgroundSurface", "attr")) - ).use { it.getColor(0, 0) } - } - private fun getImageDrawable(url: String): Drawable { val connection = URL(url).openConnection() as HttpURLConnection connection.connect() @@ -172,7 +159,7 @@ class FriendFeedInfoMenu : AbstractMenu() { createComposeAlertDialog( context.mainActivity!!, ) { - var pageIndex by remember { mutableStateOf(0) } + var pageIndex by remember { mutableIntStateOf(0) } val messages = remember { mutableStateListOf<@Composable () -> Unit>() } var totalMessages by remember { mutableIntStateOf(-1) } val coroutineScope = rememberCoroutineScope() @@ -334,12 +321,16 @@ class FriendFeedInfoMenu : AbstractMenu() { if (index > 0) { Spacer(modifier = Modifier .height(1.dp) - .background(remember { if (context.androidContext.isDarkTheme()) Color(0x1affffff) else Color(0xffeeeeee) }) + .background(remember { + if (context.androidContext.isDarkTheme()) Color(0x1affffff) else Color( + 0xffeeeeee + ) + }) .fillMaxWidth()) } Surface( - color = Color(sigColorBackgroundSurface), - contentColor = Color(sigColorTextPrimary), + color = Color(context.userInterface.actionSheetBackground), + contentColor = Color(context.userInterface.colorPrimary), ) { Row( modifier = Modifier @@ -372,9 +363,66 @@ class FriendFeedInfoMenu : AbstractMenu() { } } - override fun inject(parent: ViewGroup, view: View, viewConsumer: ((View) -> Unit)) { - val modContext = context + private val recyclerViewTag = randomTag() + private val messaging by lazy { context.feature(Messaging::class)} + + override fun onViewAdded(event: AddViewEvent) { + fun hasAvatarHeader(viewGroup: ViewGroup): Boolean { + val constraintLayout = viewGroup.getChildAt(0)?.takeIf { it.javaClass.name.endsWith("ConstraintLayout") } as? ViewGroup ?: return false + return constraintLayout.children().firstOrNull { it.javaClass.name.endsWith("AvatarView") } != null + } + + if (event.parent is FrameLayout && messaging.lastFocusedConversationType == 1 && event.view.javaClass.name.endsWith("RecyclerView")) { + event.view.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + if (event.view.tag == recyclerViewTag || !hasAvatarHeader(event.view as ViewGroup)) return@addOnLayoutChangeListener + event.view.tag = recyclerViewTag + + // remove recycler view + event.parent.removeView(event.view) + + val newLayout = LinearLayout(event.view.context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.BOTTOM + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + addView(event.view) + } + + newLayout.addView(ScrollView(newLayout.context).apply { + layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + weight = 1f; + setMargins(0, 100, 0, 0) + } + + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + injectIntoActionSheetItems(newLayout) { + it.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 5, 0, 5) + } + addView(it) + } + }) + }, 0) + event.parent.addView(newLayout) + } + } + + if (event.parent is LinearLayout && event.viewClassName.endsWith("SnapCardView") && hasAvatarHeader(event.parent)) { + val actionSheetItemsContainerLayout = (event.view as ViewGroup).getChildAt(0) as? ViewGroup ?: throw IllegalStateException("ActionSheetItemsContainerLayout not found") + injectIntoActionSheetItems(actionSheetItemsContainerLayout) { + actionSheetItemsContainerLayout.addView(it, 0) + } + } + } + + private fun injectIntoActionSheetItems(actionSheetItemsContainer: View, viewConsumer: ((View) -> Unit)) { val friendFeedMenuOptions by context.config.userInterface.friendFeedMenuButtons if (friendFeedMenuOptions.isEmpty()) return @@ -410,7 +458,7 @@ class FriendFeedInfoMenu : AbstractMenu() { ) } - modContext.features.getRuleFeatures().forEach { ruleFeature -> + context.features.getRuleFeatures().forEach { ruleFeature -> if (!friendFeedMenuOptions.contains(ruleFeature.ruleType.key)) return@forEach val ruleState = ruleFeature.getRuleState() ?: return@forEach @@ -478,7 +526,7 @@ class FriendFeedInfoMenu : AbstractMenu() { } }, onLongClick = { - view.post { + actionSheetItemsContainer.post { context.apply { closeMenu() inAppOverlay.showStatusToast( @@ -496,9 +544,11 @@ class FriendFeedInfoMenu : AbstractMenu() { } viewConsumer( - createComposeView(view.context) { + createComposeView(actionSheetItemsContainer.context) { CompositionLocalProvider( - LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = avenirNextMediumFont)) + LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = FontFamily( + Font(context.userInterface.getFontResource(600) ?: throw IllegalStateException("Avenir Next font not found"), FontWeight.Medium) + ))) ) { ComposeFriendFeedMenu() } @@ -517,16 +567,14 @@ class FriendFeedInfoMenu : AbstractMenu() { it.hasInterface(EnumScriptInterface.FRIEND_FEED_CONTEXT_MENU) } ?: return@eachModule - viewConsumer(LinearLayout(view.context).apply { + viewConsumer(LinearLayout(actionSheetItemsContainer.context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) - applyTheme(view.width, hasRadius = true) - orientation = LinearLayout.VERTICAL - addView(createComposeView(view.context) { + addView(createComposeView(actionSheetItemsContainer.context) { Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt @@ -300,7 +300,7 @@ class NewChatActionMenu : AbstractMenu() { val primaryColor = remember { if (event.view.context.isDarkTheme()) Color.White else Color.Black } val avenirNextMediumFont = remember { FontFamily( - Font(context.resources.getIdentifier("avenir_next_medium", "font"), FontWeight.Medium) + Font(context.userInterface.getFontResource(600) ?: throw IllegalStateException("Font not found"), FontWeight.Medium) ) } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaContextActionMenu.kt @@ -23,12 +23,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.content.res.use import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.impl.OperaViewerParamsOverride import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.core.ui.applyTheme +import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent -import me.rhunk.snapenhance.core.util.ktx.getId import me.rhunk.snapenhance.core.util.ktx.getIdentifier import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress import me.rhunk.snapenhance.core.wrapper.impl.ScSize @@ -37,8 +37,6 @@ import java.util.Date @SuppressLint("DiscouragedApi") class OperaContextActionMenu : AbstractMenu() { - private val contextCardsScrollView by lazy { context.resources.getId("context_cards_scroll_view") } - /* LinearLayout : - LinearLayout: @@ -53,166 +51,150 @@ class OperaContextActionMenu : AbstractMenu() { */ private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { if (viewGroup !is LinearLayout) return false - val children = ArrayList<View>() - for (i in 0 until viewGroup.getChildCount()) - children.add(viewGroup.getChildAt(i)) + val children = viewGroup.children() return if (children.any { view: View? -> view !is LinearLayout }) false else children.map { view: View -> view as LinearLayout } .any { linearLayout: LinearLayout -> - val viewChildren = ArrayList<View>() - for (i in 0 until linearLayout.childCount) viewChildren.add( - linearLayout.getChildAt( - i - ) - ) - viewChildren.any { viewChild: View -> + linearLayout.children().any { viewChild: View -> viewChild.javaClass.name.endsWith("SnapFontTextView") } } } - @SuppressLint("SetTextI18n") - override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { - try { - if (parent.parent !is ScrollView) return - val parentView = parent.parent as ScrollView - if (parentView.id != contextCardsScrollView) return - if (view !is LinearLayout) return - if (!isViewGroupButtonMenuContainer(view as ViewGroup)) return + override fun onViewAdded(event: AddViewEvent) { + val parentView = event.parent.parent as? ScrollView ?: return + val view = event.view + if (view !is LinearLayout) return + if (!isViewGroupButtonMenuContainer(view as ViewGroup)) return - val linearLayout = LinearLayout(view.context) - linearLayout.orientation = LinearLayout.VERTICAL - linearLayout.gravity = Gravity.CENTER - linearLayout.layoutParams = - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - val translation = context.translation.getCategory("opera_context_menu") - val mediaDownloader = context.feature(MediaDownloader::class) - val paramMap = mediaDownloader.lastSeenMapParams + val linearLayout = LinearLayout(view.context) + linearLayout.orientation = LinearLayout.VERTICAL + linearLayout.gravity = Gravity.CENTER + linearLayout.layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val translation = context.translation.getCategory("opera_context_menu") + val mediaDownloader = context.feature(MediaDownloader::class) + val paramMap = mediaDownloader.lastSeenMapParams - if (paramMap != null && context.config.userInterface.operaMediaQuickInfo.get()) { - val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() - val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=") - ?.substringBefore(",")?.toLongOrNull() - ?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId -> - context.database.getConversationMessageFromId( - messageId.substring(messageId.lastIndexOf(":") + 1) - .toLong() - )?.creationTimestamp - } - ?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() - val dateFormat = DateFormat.getDateTimeInstance() - val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=") - ?.substringBefore(",")?.toLongOrNull() - val expirationTimestamp = playableStorySnapRecord?.substringAfter("expirationTimestamp=") - ?.substringBefore(",")?.toLongOrNull() - ?: paramMap["SNAP_EXPIRATION_TIMESTAMP_MILLIS"]?.toString()?.toLongOrNull() + if (paramMap != null && context.config.userInterface.operaMediaQuickInfo.get()) { + val playableStorySnapRecord = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + val sentTimestamp = playableStorySnapRecord?.substringAfter("timestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["MESSAGE_ID"]?.toString()?.let { messageId -> + context.database.getConversationMessageFromId( + messageId.substring(messageId.lastIndexOf(":") + 1) + .toLong() + )?.creationTimestamp + } + ?: paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull() + val dateFormat = DateFormat.getDateTimeInstance() + val creationTimestamp = playableStorySnapRecord?.substringAfter("creationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + val expirationTimestamp = playableStorySnapRecord?.substringAfter("expirationTimestamp=") + ?.substringBefore(",")?.toLongOrNull() + ?: paramMap["SNAP_EXPIRATION_TIMESTAMP_MILLIS"]?.toString()?.toLongOrNull() - val mediaSize = paramMap["snap_size"]?.let { ScSize(it) } - val durationMs = paramMap["media_duration_ms"]?.toString() + val mediaSize = paramMap["snap_size"]?.let { ScSize(it) } + val durationMs = paramMap["media_duration_ms"]?.toString() - val stringBuilder = StringBuilder().apply { - if (sentTimestamp != null) { - append(translation.format("sent_at", "date" to dateFormat.format(Date(sentTimestamp)))) - append("\n") - } - if (creationTimestamp != null) { - append(translation.format("created_at", "date" to dateFormat.format(Date(creationTimestamp)))) - append("\n") - } - if (expirationTimestamp != null) { - append(translation.format("expires_at", "date" to dateFormat.format(Date(expirationTimestamp)))) - append("\n") - } - if (mediaSize != null) { - append(translation.format("media_size", "size" to "${mediaSize.first}x${mediaSize.second}")) - append("\n") - } - if (durationMs != null) { - append(translation.format("media_duration", "duration" to durationMs)) - append("\n") - } - if (last() == '\n') deleteCharAt(length - 1) + val stringBuilder = StringBuilder().apply { + if (sentTimestamp != null) { + append(translation.format("sent_at", "date" to dateFormat.format(Date(sentTimestamp)))) + append("\n") } - - if (stringBuilder.isNotEmpty()) { - linearLayout.addView(TextView(view.context).apply { - text = stringBuilder.toString() - setPadding(40, 10, 0, 0) - }) + if (creationTimestamp != null) { + append(translation.format("created_at", "date" to dateFormat.format(Date(creationTimestamp)))) + append("\n") } + if (expirationTimestamp != null) { + append(translation.format("expires_at", "date" to dateFormat.format(Date(expirationTimestamp)))) + append("\n") + } + if (mediaSize != null) { + append(translation.format("media_size", "size" to "${mediaSize.first}x${mediaSize.second}")) + append("\n") + } + if (durationMs != null) { + append(translation.format("media_duration", "duration" to durationMs)) + append("\n") + } + if (last() == '\n') deleteCharAt(length - 1) } - if (context.config.global.videoPlaybackRateSlider.get()) { - val operaViewerParamsOverride = context.feature(OperaViewerParamsOverride::class) - - linearLayout.addView(createComposeView(view.context) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) - ) { - var value by remember { mutableFloatStateOf(operaViewerParamsOverride.currentPlaybackRate) } - Slider( - value = value, - onValueChange = { - value = it - operaViewerParamsOverride.currentPlaybackRate = it - }, - valueRange = 0.1F..4.0F, - steps = 0, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = "x" + value.toString().take(4), - color = remember { - view.context.theme.obtainStyledAttributes( - intArrayOf(view.context.resources.getIdentifier("sigColorTextPrimary", "attr")) - ).use { Color(it.getColor(0, 0)) } - }, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } - }.apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) + if (stringBuilder.isNotEmpty()) { + linearLayout.addView(TextView(view.context).apply { + text = stringBuilder.toString() + setPadding(40, 10, 0, 0) }) } + } - if (context.config.downloader.downloadContextMenu.get()) { - linearLayout.addView(Button(view.context).apply { - text = translation["download"] - setOnClickListener { - mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false) - parentView.triggerCloseTouchEvent() - } - setOnLongClickListener { - context.vibrateLongPress() - mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true) - parentView.triggerCloseTouchEvent() - true - } - applyTheme(isAmoled = false) - }) - } + if (context.config.global.videoPlaybackRateSlider.get()) { + val operaViewerParamsOverride = context.feature(OperaViewerParamsOverride::class) - if (context.isDeveloper) { - linearLayout.addView(Button(view.context).apply { - text = translation["show_debug_info"] - setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } - applyTheme(isAmoled = false) - }) - } + linearLayout.addView(createComposeView(view.context) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + var value by remember { mutableFloatStateOf(operaViewerParamsOverride.currentPlaybackRate) } + Slider( + value = value, + onValueChange = { + value = it + operaViewerParamsOverride.currentPlaybackRate = it + }, + valueRange = 0.1F..4.0F, + steps = 0, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "x" + value.toString().take(4), + color = remember { + Color(context.userInterface.colorPrimary) + }, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + }.apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + }) + } - (view as ViewGroup).addView(linearLayout, 0) - } catch (e: Throwable) { - context.log.error("Error while injecting OperaContextActionMenu", e) + if (context.config.downloader.downloadContextMenu.get()) { + linearLayout.addView(Button(view.context).apply { + text = translation["download"] + setOnClickListener { + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false) + parentView.triggerCloseTouchEvent() + } + setOnLongClickListener { + context.vibrateLongPress() + mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true) + parentView.triggerCloseTouchEvent() + true + } + this@OperaContextActionMenu.context.userInterface.applyActionButtonTheme(this) + }) } + + if (context.isDeveloper) { + linearLayout.addView(Button(view.context).apply { + text = translation["show_debug_info"] + setOnClickListener { mediaDownloader.showLastOperaDebugMediaInfo() } + this@OperaContextActionMenu.context.userInterface.applyActionButtonTheme(this) + }) + } + + (view as? ViewGroup)?.addView(linearLayout, 0) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/OperaViewerIcons.kt @@ -1,6 +1,5 @@ package me.rhunk.snapenhance.core.ui.menu.impl -import android.graphics.Color import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -9,32 +8,46 @@ import android.widget.ImageView import android.widget.LinearLayout import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.Color import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.core.features.impl.messaging.AutoMarkAsRead import me.rhunk.snapenhance.core.ui.children import me.rhunk.snapenhance.core.ui.iterateParent import me.rhunk.snapenhance.core.ui.menu.AbstractMenu import me.rhunk.snapenhance.core.ui.triggerCloseTouchEvent -import me.rhunk.snapenhance.core.util.ktx.getDimens -import me.rhunk.snapenhance.core.util.ktx.getDrawable import me.rhunk.snapenhance.core.util.ktx.vibrateLongPress class OperaViewerIcons : AbstractMenu() { - private val downloadSvgDrawable by lazy { context.resources.getDrawable("svg_download", context.androidContext.theme) } - private val eyeSvgDrawable by lazy { context.resources.getDrawable("svg_eye_24x24", context.androidContext.theme) } - private val actionMenuIconSize by lazy { context.resources.getDimens("action_menu_icon_size") } - private val actionMenuIconMargin by lazy { context.resources.getDimens("action_menu_icon_margin") } - private val actionMenuIconMarginTop by lazy { context.resources.getDimens("action_menu_icon_margin_top") } + private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) } + private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) } + private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) } - override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { + override fun onViewAdded(event: AddViewEvent) { + if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { + val viewGroup = event.view as? ViewGroup ?: return + if ( + viewGroup.childCount == 0 || + viewGroup.children().any { it !is ImageView } || + event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") } + ) return + inject(viewGroup) + } + } + + private fun inject(parent: ViewGroup) { val mediaDownloader = context.feature(MediaDownloader::class) if (context.config.downloader.operaDownloadButton.get()) { - parent.addView(LinearLayout(view.context).apply { + parent.addView(LinearLayout(parent.context).apply { orientation = LinearLayout.VERTICAL layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.WRAP_CONTENT, @@ -58,15 +71,13 @@ class OperaViewerIcons : AbstractMenu() { override fun onViewDetachedFromWindow(v: View) {} }) - addView(ImageView(view.context).apply { - setImageDrawable(downloadSvgDrawable) - setColorFilter(Color.WHITE) - layoutParams = LinearLayout.LayoutParams( - actionMenuIconSize, - actionMenuIconSize - ).apply { - setMargins(0, 0, 0, actionMenuIconMargin * 2) - } + addView(createComposeView(parent.context) { + Icon( + imageVector = Icons.Outlined.Download, + tint = Color.White, + contentDescription = null + ) + }.apply { setOnClickListener { mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false) } @@ -75,9 +86,14 @@ class OperaViewerIcons : AbstractMenu() { mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true) true } + layoutParams = LinearLayout.LayoutParams( + actionMenuIconSize, + actionMenuIconSize + ).apply { + setMargins(0, 0, 0, actionMenuIconMargin * 2) + } }) }, 0) - } if (context.config.messaging.markSnapAsSeenButton.get()) { @@ -89,28 +105,13 @@ class OperaViewerIcons : AbstractMenu() { ?.let { return it[0] to it[2] } } - parent.addView(ImageView(view.context).apply { - setImageDrawable(eyeSvgDrawable) - setColorFilter(Color.WHITE) - addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - v.visibility = View.GONE - this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) { - delay(300) - v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE - } - } - override fun onViewDetachedFromWindow(v: View) {} - }) - layoutParams = FrameLayout.LayoutParams( - (actionMenuIconSize * 1.4).toInt(), - (actionMenuIconSize * 1.4).toInt() - ).apply { - setMargins(0, 0, 0, actionMenuIconMarginTop * 2 + (80 * context.resources.displayMetrics.density).toInt()) - marginEnd = actionMenuIconMarginTop * 2 - marginStart = actionMenuIconMarginTop * 2 - gravity = Gravity.BOTTOM or Gravity.END - } + parent.addView(createComposeView(parent.context) { + Icon( + imageVector = Icons.Default.RemoveRedEye, + tint = Color.White, + contentDescription = null + ) + }.apply { setOnClickListener { this@OperaViewerIcons.context.apply { coroutineScope.launch { @@ -144,7 +145,28 @@ class OperaViewerIcons : AbstractMenu() { } } } - }, 0) + + addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.visibility = View.GONE + this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) { + delay(250) + v.visibility = if (getMessageId() != null) View.VISIBLE else View.GONE + } + } + override fun onViewDetachedFromWindow(v: View) {} + }) + + layoutParams = FrameLayout.LayoutParams( + (actionMenuIconSize * 1.5).toInt(), + (actionMenuIconSize * 1.5).toInt() + ).apply { + setMargins(0, 0, 0, actionMenuIconMarginTop * 2 + this@OperaViewerIcons.context.userInterface.dpToPx(80)) + marginEnd = actionMenuIconMarginTop * 2 + marginStart = actionMenuIconMarginTop * 2 + gravity = Gravity.BOTTOM or Gravity.END + } + }) } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsGearInjector.kt @@ -1,77 +0,0 @@ -package me.rhunk.snapenhance.core.ui.menu.impl - -import android.annotation.SuppressLint -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.ImageView -import androidx.core.content.res.use -import me.rhunk.snapenhance.common.ui.OverlayType -import me.rhunk.snapenhance.core.ui.menu.AbstractMenu -import me.rhunk.snapenhance.core.util.ktx.getDimens -import me.rhunk.snapenhance.core.util.ktx.getDrawable -import me.rhunk.snapenhance.core.util.ktx.getId -import me.rhunk.snapenhance.core.util.ktx.getStyledAttributes - - -@SuppressLint("DiscouragedApi") -class SettingsGearInjector : AbstractMenu() { - override fun inject(parent: ViewGroup, view: View, viewConsumer: (View) -> Unit) { - if (context.config.userInterface.hideSettingsGear.get()) return - val hovaNavMapIcon = parent.findViewById<View>(context.resources.getId("hova_nav_map_icon")) - val firstView = hovaNavMapIcon ?: (view as ViewGroup).getChildAt(0) - - val ngsHovaHeaderSearchIconBackgroundMarginLeft = context.resources.getDimens("ngs_hova_header_search_icon_background_margin_left") - - (view as ViewGroup).clipChildren = false - view.addView(FrameLayout(parent.context).apply { - visibility = View.GONE - post { - layoutParams = FrameLayout.LayoutParams(firstView.layoutParams.width, firstView.layoutParams.height).apply { - y = 0f - // TODO: find a better way to calculate the x position with the correct padding when Simple Snapchat is active - x = -(ngsHovaHeaderSearchIconBackgroundMarginLeft + firstView.layoutParams.width).toFloat() + - (hovaNavMapIcon.takeIf { it != null }?.let { - 7 * context.resources.displayMetrics.density - } ?: 0f) - } - visibility = View.VISIBLE - } - - isClickable = true - - setOnClickListener { - this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS) - } - - parent.setOnTouchListener { _, event -> - if (view.visibility == View.INVISIBLE || view.alpha == 0F) return@setOnTouchListener false - - val viewLocation = IntArray(2) - getLocationOnScreen(viewLocation) - - val x = event.rawX - viewLocation[0] - val y = event.rawY - viewLocation[1] - - if (x > 0 && x < width && y > 0 && y < height) { - performClick() - } - - false - } - backgroundTintList = firstView.backgroundTintList - background = firstView.background - - addView(ImageView(context).apply { - layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 17).apply { - gravity = android.view.Gravity.CENTER - } - setImageDrawable(context.resources.getDrawable("svg_settings_32x32", context.theme)) - // TODO: find a better way to tint the icon when Simple Snapchat is active - context.resources.getStyledAttributes("headerButtonOpaqueIconTint", context.theme).use { - imageTintList = it.getColorStateList(0) - } - }) - }) - } -}- \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/SettingsMenu.kt @@ -1,6 +1,39 @@ package me.rhunk.snapenhance.core.ui.menu.impl +import android.view.View +import android.widget.FrameLayout +import me.rhunk.snapenhance.common.ui.OverlayType import me.rhunk.snapenhance.core.ui.menu.AbstractMenu +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook +import me.rhunk.snapenhance.core.util.ktx.getId +import me.rhunk.snapenhance.core.util.ktx.getIdentifier class SettingsMenu : AbstractMenu() { + private val hovaHeaderSearchIconId by lazy { + context.resources.getId("hova_header_search_icon") + } + + private val ngsChatLabel by lazy { + context.resources.run { + getString(getIdentifier("ngs_chat_label", "string")) + } + } + + override fun init() { + context.androidContext.classLoader.loadClass("com.snap.ui.view.SnapFontTextView").hook("setText", HookStage.BEFORE) { param -> + val view = param.thisObject<View>() + if ((view.parent as? FrameLayout)?.findViewById<View>(hovaHeaderSearchIconId) != null) { + view.post { + view.setOnClickListener { + context.bridgeClient.openOverlay(OverlayType.SETTINGS) + } + } + + if (param.argNullable<String>(0) == ngsChatLabel) { + param.setArg(0, "SnapEnhance") + } + } + } + } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/ktx/AndroidExt.kt @@ -10,11 +10,19 @@ import android.os.VibrationEffect import android.os.Vibrator import androidx.core.graphics.ColorUtils import me.rhunk.snapenhance.common.Constants +import me.rhunk.snapenhance.common.logger.AbstractLogger +val notFoundCache = mutableSetOf<String>() @SuppressLint("DiscouragedApi") fun Resources.getIdentifier(name: String, type: String): Int { - return getIdentifier(name, type, Constants.SNAPCHAT_PACKAGE_NAME) + return getIdentifier(name, type, Constants.SNAPCHAT_PACKAGE_NAME).also { id -> + if (id != 0) return@also + "$type#$name".takeIf { it !in notFoundCache}?.let { + AbstractLogger.directDebug("Resource not found: $it") + notFoundCache.add(it) + } + } } fun Resources.getId(name: String): Int { @@ -48,11 +56,10 @@ fun Context.vibrateLongPress() { getSystemService(Vibrator::class.java).vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) } -@SuppressLint("DiscouragedApi") fun Context.isDarkTheme(): Boolean { return theme.obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", packageName)) + intArrayOf(android.R.attr.colorPrimary) ).getColor(0, 0).let { - ColorUtils.calculateLuminance(it) > 0.5 + ColorUtils.calculateLuminance(it) < 0.5 } } \ No newline at end of file diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/AbstractClassMapper.kt @@ -83,7 +83,8 @@ abstract class AbstractClassMapper( } } - fun writeFromJson(json: JsonObject) { + fun writeFromJson(json: JsonObject): List<String> { + val warns = mutableListOf<String>() values.forEach { (key, value) -> runCatching { when (value) { @@ -95,7 +96,11 @@ abstract class AbstractClassMapper( }.onFailure { Log.e("Mapper","Failed to serialize property $key") } + if (json.get(key).let { it.isJsonNull || (it.isJsonPrimitive && it.asString == "null") }) { + warns.add("Failed to serialize property $key in $mapperName") + } } + return warns } fun mapper(task: MapperContext.() -> Unit) { diff --git a/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt b/mapper/src/main/kotlin/me/rhunk/snapenhance/mapper/ClassMapper.kt @@ -17,6 +17,7 @@ class ClassMapper( private vararg val mappers: AbstractClassMapper = DEFAULT_MAPPERS, ) { private val classes = mutableListOf<ClassDef>() + private val warnings = mutableListOf<String>() companion object { val DEFAULT_MAPPERS get() = arrayOf( @@ -73,6 +74,8 @@ class ClassMapper( } } + fun getWarns() = warnings + suspend fun run(): JsonObject { val context = MapperContext(classes.associateBy { it.type }) @@ -87,7 +90,7 @@ class ClassMapper( val outputJson = JsonObject() mappers.forEach { mapper -> outputJson.add(mapper.mapperName, JsonObject().apply { - mapper.writeFromJson(this) + warnings.addAll(mapper.writeFromJson(this)) }) } return outputJson diff --git a/native/build.gradle.kts b/native/build.gradle.kts @@ -11,7 +11,7 @@ android { compileSdk = 34 buildToolsVersion = "34.0.0" - ndkVersion = "26.3.11579264" + ndkVersion = "27.0.12077973" buildFeatures { buildConfig = true