diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d2d24343..90a87b42 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -55,9 +55,7 @@ class Global : ConfigContainer() { } } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) - val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply { - profile.set("max") - } + val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() } val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() } val disableMetrics = boolean("disable_metrics") { requireRestart() } val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 06e7c8cd..ba554ea0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -128,6 +128,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } + // Background Watchdog: Periodically refreshes UI and verifies engine health + this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) { + while (isActive && engineActive.get()) { + if (autoOpenConfig.globalState == true) { + updateStatusNotification() + } + delay(5000) + } + } + setupReceivers() startEngineWorker() setupDetector() @@ -158,12 +168,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A processSnapItem(item) lastSnapProcessedAt.set(System.currentTimeMillis()) - // HIGH SPEED: 10ms floor for 20+ snaps/s + // Process at natural network speed when safety is disabled val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() if (isSafe) { delay(Random.nextLong(baseDelay, baseDelay + 200)) } else { - delay(baseDelay.coerceAtMost(10)) + if (baseDelay > 0) delay(baseDelay) } if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { @@ -175,6 +185,13 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } private suspend fun processSnapItem(item: SnapQueueItem) { + // Verify database state on background thread before processing + val dbMessage = withContext(Dispatchers.IO) { this@AutoOpenSnaps.context.database.getConversationMessageFromId(item.messageId) } + if (dbMessage?.isViewedByUser == 1) { + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + return + } + currentStatusText = "Active"; updateStatusNotification() var success = false val startTime = System.currentTimeMillis() @@ -186,7 +203,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A delay(1000) } - success = withContext(Dispatchers.IO) { performOpen(item) } + success = performOpen(item) if (success) { synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() @@ -200,23 +217,24 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A logError("Engine failed to open Snap: ${item.messageId}") synchronized(queuedSnaps) { queuedSnaps.remove(item) } currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() - openedSnapsIds.remove(item.messageId) } } private suspend fun performOpen(item: SnapQueueItem): Boolean { val manager = messaging.conversationManager ?: return false - return suspendCancellableCoroutine { cont -> - runCatching { - manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> - if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } - else if (item.serverMessageId != 0L) { - manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> - cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") - } - } else { cont.resume(false) } - } - }.onFailure { logError("Bridge Error", it); cont.resume(false) } + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + runCatching { + manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> + if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } + else if (item.serverMessageId != 0L) { + manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> + cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") + } + } else { cont.resume(false) } + } + }.onFailure { logError("Bridge Error", it); cont.resume(false) } + } } } @@ -250,13 +268,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe val message = event.message if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe - val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe + + val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe val serverMessageId = message.orderKey ?: 0L val contentType = message.messageContent?.contentType if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe if (!canUseRule(conversationId)) return@subscribe + + // Prevent re-queueing the same message while it is currently being processed if (openedSnapsIds.contains(clientMessageId)) return@subscribe openedSnapsIds.add(clientMessageId) @@ -295,7 +317,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - // RESILIENT WIFI CHECK: Iterates through all networks to find ANY WiFi transport (VPN aware) + // Check for any available network with a WiFi or Ethernet transport return cm.allNetworks.any { network -> cm.getNetworkCapabilities(network)?.let { caps -> caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 68734871..91e82fc8 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -1,7 +1,13 @@ package me.eternal.purrfectsnap.core.features.impl.messaging +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Build import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -64,11 +70,25 @@ import kotlin.time.toDuration class SendOverride : Feature("Send Override") { companion object { private const val NOTIFICATION_CHANNEL_ID = "scheduled_send" + private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status" + private const val STATUS_NOTIFICATION_ID = 54322 + private const val COMPLETION_NOTIFICATION_ID = 54323 + + const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME" + const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP" + private val internalMultipartSend = ThreadLocal.withInitial { false } private var queuedOriginalItemRepeatCount = 0 private var queuedOriginalItemRepeatOverrideType: String? = null private var queuedOriginalItemRepeatSnapDurationMs: Int? = null + // Notification & Loop Tracking + private var totalRepeatCount = 0 + private var processedRepeatCount = 0 + private var currentRecipientName: String = "Unknown" + private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false) + private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false) + private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) { queuedOriginalItemRepeatCount = repeatCount queuedOriginalItemRepeatOverrideType = overrideType @@ -116,6 +136,80 @@ class SendOverride : Feature("Send Override") { private val backgroundHookLock = Any() private var backgroundHookRefs = 0 private var backgroundHooks: List? = null + private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true) + + private fun createContinuousNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CONTINUOUS_SEND_CHANNEL_ID, + "Continuous Send", + NotificationManager.IMPORTANCE_LOW + ) + channel.description = "Progress status for continuous snap sending" + notificationManager.createNotificationChannel(channel) + } + } + + private fun updateContinuousSendNotification() { + if (!engineActive.get() || isStopped.get()) return + + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val remaining = queuedOriginalItemRepeatCount + val processed = processedRepeatCount + val total = totalRepeatCount + val isWorking = (remaining > 0 || (total > 0 && processed < total)) && !isStopped.get() + + if (!isWorking) { + notificationManager.cancel(STATUS_NOTIFICATION_ID) + showCompletionNotification(processed, total) + return + } + + val progressPercent = if (total > 0) (processed * 100) / total else 0 + + val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSmallIcon(android.R.drawable.ic_popup_sync) // The Industrial Loop icon + .setColor(0xFF3498DB.toInt()) // Industrial Purple/Blue tint + .setContentTitle("Sending Snaps to $currentRecipientName") + .setContentText("Progress: $processed / $total ($progressPercent%)") + .setSubText("$processed / $total") + .setProgress(total, processed, false) + + val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause" + builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(ACTION_PAUSE_RESUME)).build()) + builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build()) + + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) + } + + private fun showCompletionNotification(sent: Int, total: Int) { + val title = if (isStopped.get()) "Continuous Send Stopped" else "Continuous Send Finished" + val content = "Successfully sent $sent / $total snaps to $currentRecipientName" + + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) + .setSmallIcon(android.R.drawable.checkbox_on_background) // Emerald Green Checkmark + .setColor(0xFF2ECC71.toInt()) // Emerald Green tint + .setContentTitle(title) + .setContentText(content) + .setAutoCancel(true) + + notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build()) + } + + private fun createPendingIntent(action: String): PendingIntent { + val intent = Intent(action).setPackage(context.androidContext.packageName) + return PendingIntent.getBroadcast( + context.androidContext, + action.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + private fun acquireScheduledSendBackground(): () -> Unit { if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {} var enableFailed = false @@ -196,7 +290,35 @@ class SendOverride : Feature("Send Override") { @OptIn(ExperimentalLayoutApi::class) override fun init() { createNotificationChannel() - + createContinuousNotificationChannel() + + val actionReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_PAUSE_RESUME -> { + isPaused.set(!isPaused.get()) + updateContinuousSendNotification() + } + ACTION_STOP -> { + isStopped.set(true) + if (isPaused.get()) { + isPaused.set(false) + } + updateContinuousSendNotification() + } + } + } + } + val filter = IntentFilter().apply { + addAction(ACTION_PAUSE_RESUME) + addAction(ACTION_STOP) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.androidContext.registerReceiver(actionReceiver, filter) + } + val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get() var postSavePolicy: Int? = null @@ -1429,6 +1551,11 @@ class SendOverride : Feature("Send Override") { invokeOriginalAndRestoreResult(event) } } else if (MediaFilePicker.hasReusableOriginalItem()) { + totalRepeatCount = repeatCount + processedRepeatCount = 1 + currentRecipientName = recipientNameForTask + updateContinuousSendNotification() + queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) attachQueuedRepeatCallbacks(event) if (sendMedia(finalSelectedType, selectedSnapDurationMs)) { @@ -1437,6 +1564,11 @@ class SendOverride : Feature("Send Override") { clearQueuedOriginalItemRepeats() } } else { + totalRepeatCount = repeatCount + processedRepeatCount = 0 + currentRecipientName = recipientNameForTask + updateContinuousSendNotification() + sendRepeatedMediaManual( repeatCount, finalSelectedType, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt index 5f798a23..daedd055 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt @@ -2,29 +2,35 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks import android.animation.ValueAnimator import android.app.Activity +import android.app.Dialog import android.database.sqlite.SQLiteDatabase import android.hardware.camera2.CaptureRequest import android.media.MediaRecorder import android.os.Build -import android.transition.Transition import android.os.HandlerThread import android.os.Process +import android.transition.Transition import android.util.Range import android.view.View +import android.view.TextureView import android.view.ViewPropertyAnimator +import android.view.WindowManager import android.view.animation.Animation import android.widget.OverScroller import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager -import java.lang.Thread -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.ThreadPoolExecutor import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.hook.hookConstructor import okhttp3.Dispatcher +import java.lang.Thread +import java.lang.reflect.Method +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.atomic.AtomicBoolean class PerformanceMode : Feature("Performance Mode") { override fun init() { @@ -43,7 +49,7 @@ class PerformanceMode : Feature("Performance Mode") { val minimumCoreThreads = if (isMaxProfile) 16 else 8 val prefetchItemCount = if (isMaxProfile) 24 else 12 val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L - val maxScrollDurationMs = if (isMaxProfile) 120 else 180 + val maxScrollDurationMs = if (isMaxProfile) 72 else 180 val preferredRefreshRate = if (isMaxProfile) 120f else 90f context.log.info( @@ -53,52 +59,33 @@ class PerformanceMode : Feature("Performance Mode") { runCatching { ValueAnimator.setFrameDelay(0L) - context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode") } - fun firstHitLogger(name: String): (String) -> Unit { - val didLog = AtomicBoolean(false) - return { details -> - if (didLog.compareAndSet(false, true)) { - context.log.info("First hit: $name | $details", "PerformanceMode") - } - } - } - - val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor") - val handlerThreadStartLog = firstHitLogger("HandlerThread.start") - val threadStartLog = firstHitLogger("Thread.start") - val executorLog = firstHitLogger("ThreadPoolExecutor.constructor") - val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor") - val animatorLog = firstHitLogger("ValueAnimator.getDurationScale") - val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration") - val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration") - val transitionDurationLog = firstHitLogger("Transition.setDuration") - val animationDurationLog = firstHitLogger("Animation.setDuration") - val recyclerCtorLog = firstHitLogger("RecyclerView.constructor") - val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter") - val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager") - val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase") - val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase") - val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate") - val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set") - val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode") - val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate") - val overScrollerLog = firstHitLogger("OverScroller.startScroll") - fun isPerformanceSensitiveThread(name: String?): Boolean { val normalizedName = name?.lowercase() ?: return false - return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk").any { + return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any { normalizedName.contains(it) } } + fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long { + if (durationMs <= 0L) return durationMs + return durationMs.coerceAtMost(maxDurationMs) + } + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!isMaxProfile) return@subscribe + val url = event.url + if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) { + event.canceled = true + } + } + HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param -> if (param.args().size < 2) return@hookConstructor val threadName = param.argNullable(0) if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor param.setArg(1, threadPriority) - handlerThreadConstructorLog("name=$threadName priority=$threadPriority") } HandlerThread::class.java.hook("start", HookStage.AFTER) { param -> @@ -110,16 +97,14 @@ class PerformanceMode : Feature("Performance Mode") { Process.setThreadPriority(tid, threadPriority) } } - handlerThreadStartLog("name=${thread.name} tid=${thread.threadId} priority=$threadPriority") } Thread::class.java.hook("start", HookStage.AFTER) { param -> val thread = param.thisObject() if (!isPerformanceSensitiveThread(thread.name)) return@hook runCatching { - thread.priority = Thread.MAX_PRIORITY + thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1 } - threadStartLog("name=${thread.name} priority=${thread.priority}") } ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param -> @@ -131,7 +116,6 @@ class PerformanceMode : Feature("Performance Mode") { } executor.allowCoreThreadTimeOut(false) executor.prestartAllCoreThreads() - executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}") } } @@ -140,13 +124,11 @@ class PerformanceMode : Feature("Performance Mode") { runCatching { dispatcher.maxRequests = maxRequests dispatcher.maxRequestsPerHost = maxRequestsPerHost - dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}") } } ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param -> param.setResult(durationScale) - animatorLog("durationScale=$durationScale") } ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -155,7 +137,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - animatorDurationLog("requested=$original applied=${param.arg(0)}") } ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -164,7 +145,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - viewAnimatorDurationLog("requested=$original applied=${param.arg(0)}") } Transition::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -173,7 +153,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - transitionDurationLog("requested=$original applied=${param.arg(0)}") } Animation::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -182,28 +161,25 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - animationDurationLog("requested=$original applied=${param.arg(0)}") } RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) recyclerView.overScrollMode = View.OVER_SCROLL_NEVER - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + recyclerView.recycledViewPool.setMaxRecycledViews(0, 20) if (isMaxProfile) { recyclerView.itemAnimator = null } - recyclerCtorLog("cache=$recyclerViewCacheSize max=$isMaxProfile class=${recyclerView::class.java.name}") } RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + recyclerView.recycledViewPool.setMaxRecycledViews(0, 20) if (isMaxProfile) { recyclerView.itemAnimator = null } - recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable(0)?.javaClass?.name}") } RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param -> @@ -212,15 +188,13 @@ class PerformanceMode : Feature("Performance Mode") { when (layoutManager) { is LinearLayoutManager -> { layoutManager.isItemPrefetchEnabled = true - layoutManager.initialPrefetchItemCount = prefetchItemCount + layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12) } is StaggeredGridLayoutManager -> { layoutManager.isItemPrefetchEnabled = true layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS } } - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) - recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount") } fun SQLiteDatabase.applyPerformancePragmas() { @@ -233,25 +207,21 @@ class PerformanceMode : Feature("Performance Mode") { } SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param -> - (param.getResult() as? SQLiteDatabase)?.also { - it.applyPerformancePragmas() - sqliteOpenLog("path=${param.argNullable(0)}") - } + (param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas() } SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param -> - (param.getResult() as? SQLiteDatabase)?.also { - it.applyPerformancePragmas() - sqliteCreateLog("path=${param.argNullable(0)}") - } + (param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas() } MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param -> val currentRate = param.arg(0) - if (currentRate < minimumFrameRate) { - param.setArg(0, minimumFrameRate) + val applied = currentRate + .coerceAtLeast(if (isMaxProfile) 30 else 24) + .coerceAtMost(if (isMaxProfile) 60 else 45) + if (applied != currentRate) { + param.setArg(0, applied) } - mediaRecorderLog("requested=$currentRate applied=${param.arg(0)}") } OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param -> @@ -261,29 +231,84 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(4, updated) } - overScrollerLog("requested=$original applied=${param.arg(4)}") } } - CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param -> - val key = param.arg>(0) - when (key) { - CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_FAST) - CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_FAST) - CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_FAST) - CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_FAST) - CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) - CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE -> param.setArg(1, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) - CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE -> { - val currentRange = param.argNullable(1) as? Range<*> - val lower = (currentRange?.lower as? Int) ?: minimumFrameRate - val upper = (currentRange?.upper as? Int) ?: minimumFrameRate - if (upper < minimumFrameRate) { - param.setArg(1, Range(lower.coerceAtMost(minimumFrameRate), minimumFrameRate)) - } + OverScroller::class.java.hook("fling", HookStage.BEFORE) { param -> + if (param.args().size >= 10) { + val overX = param.arg(8) + val overY = param.arg(9) + if (overX != 0) param.setArg(8, 0) + if (overY != 0) param.setArg(9, 0) + } + } + + runCatching { + val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView") + val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions") + val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply { + isAccessible = true + } + + fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? { + return nativeMapViewClass.findRestrictedMethod { method -> + method.name == name && predicate(method) + }?.apply { + isAccessible = true } } - captureRequestLog("key=${key.name} value=${param.argNullable(1)}") + + val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 } + val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType } + val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType } + val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType } + val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType } + val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name } + + nativeMapViewClass.hookConstructor(HookStage.AFTER) { param -> + val nativeMapView = param.thisObject() + runCatching { + nativeSetPrefetchTiles?.invoke(nativeMapView, true) + nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6) + nativeSetTransitionDelay?.invoke(nativeMapView, 0L) + nativeSetTransitionDuration?.invoke(nativeMapView, 0L) + nativeSetTransitionOptions?.invoke( + nativeMapView, + transitionOptionsCtor.newInstance(0L, 0L, false) + ) + nativeCancelTransitions?.invoke(nativeMapView) + } + } + + nativeMapViewClass.findRestrictedMethod { method -> + method.name == "g" && + method.parameterCount == 6 && + method.parameterTypes.last() == Long::class.javaPrimitiveType + }?.hook(HookStage.BEFORE) { param -> + val original = param.arg(5) + val applied = original.coerceAtMost(16L) + if (applied != original) { + param.setArg(5, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + } + + nativeMapViewClass.findRestrictedMethod { method -> + method.name == "v" && + method.parameterCount == 3 && + method.parameterTypes[0] == Double::class.javaPrimitiveType && + method.parameterTypes[1] == Double::class.javaPrimitiveType && + method.parameterTypes[2] == Long::class.javaPrimitiveType + }?.hook(HookStage.BEFORE) { param -> + val original = param.arg(2) + val applied = original.coerceAtMost(8L) + if (applied != original) { + param.setArg(2, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + } + }.onFailure { + context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode") } fun applyActivityPerformanceTuning(activity: Activity) { @@ -295,12 +320,10 @@ class PerformanceMode : Feature("Performance Mode") { activity.window.attributes = activity.window.attributes.apply { this.preferredRefreshRate = targetRefreshRate } - refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate") } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) { runCatching { activity.window.setSustainedPerformanceMode(true) - sustainedModeLog("activity=${activity::class.java.name}") } } } @@ -308,5 +331,24 @@ class PerformanceMode : Feature("Performance Mode") { onNextActivityCreate { applyActivityPerformanceTuning(it) } + + Dialog::class.java.hook("show", HookStage.AFTER) { param -> + val dialog = param.nullableThisObject() as? Dialog ?: return@hook + val window = dialog.window ?: return@hook + runCatching { + window.setWindowAnimations(0) + window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + window.attributes = window.attributes.apply { + flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + } + } + } + + TextureView::class.java.hookConstructor(HookStage.AFTER) { param -> + val textureView = param.thisObject() + runCatching { + textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + } } }