Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a1114069 | ||
|
|
d724e5c615 |
@@ -151,22 +151,27 @@ class FFMpegProcessor(
|
||||
}
|
||||
|
||||
val outputArguments = ArgumentList().apply {
|
||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
fun applyVideoArguments() {
|
||||
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
when (args.action) {
|
||||
Action.DOWNLOAD_DASH -> {
|
||||
applyVideoArguments()
|
||||
outputArguments += "-ss" to "'${args.startTime}ms'"
|
||||
if (args.duration != null) {
|
||||
outputArguments += "-t" to "'${args.duration}ms'"
|
||||
}
|
||||
}
|
||||
Action.MERGE_OVERLAY -> {
|
||||
applyVideoArguments()
|
||||
inputArguments += "-i" to args.overlay!!.absolutePath
|
||||
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
||||
}
|
||||
@@ -174,8 +179,9 @@ class FFMpegProcessor(
|
||||
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
outputArguments -= "-c:v"
|
||||
args.videoCodec?.let {
|
||||
applyVideoArguments()
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to it
|
||||
} ?: run {
|
||||
outputArguments += "-vn"
|
||||
@@ -186,6 +192,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
}
|
||||
Action.MERGE_MEDIA -> {
|
||||
applyVideoArguments()
|
||||
inputArguments.clear()
|
||||
val filesInfo = args.inputs.mapNotNull { file ->
|
||||
runCatching {
|
||||
|
||||
@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.beginTransaction()
|
||||
try {
|
||||
friends.forEach { friend ->
|
||||
// Industrial Filter: Only update existing friends, never auto-insert new ones.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
friend.selfieId,
|
||||
friend.userId
|
||||
)
|
||||
)
|
||||
|
||||
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
// Industrial Filter: Only update existing groups.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
group.participantsCount,
|
||||
group.conversationId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
// Notify all observers with the updated data from the database
|
||||
val allFriends = getFriends(descOrder = true)
|
||||
val allGroups = getGroups()
|
||||
messagingDataFlow.tryEmit(allFriends to allGroups)
|
||||
// Notify all observers with the raw sync data (AddFriendDialog needs this)
|
||||
messagingDataFlow.tryEmit(friends to groups)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
title = managerContext.translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
confirmButtonText = managerContext.translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
text = managerContext.translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
@@ -140,7 +140,9 @@ class ManageRuleFeature : Routes.Route() {
|
||||
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getRuleIds(currentRuleType.key)
|
||||
}
|
||||
val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
|
||||
val currentRuleIdSet = remember(currentRuleIds.size) {
|
||||
currentRuleIds.toSet()
|
||||
}
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -166,7 +168,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onFriendState = { friend, state ->
|
||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -174,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
ruleIdsSet.contains(friend.userId)
|
||||
currentRuleIdSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
ruleIdsSet.contains(group.conversationId)
|
||||
currentRuleIdSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -67,12 +67,14 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
// Real-time synchronization from the bridge
|
||||
context.database.messagingDataFlow.collect { (friends, groups) ->
|
||||
context.database.messagingDataFlow.collect {
|
||||
withContext(Dispatchers.IO) {
|
||||
val sortedFriends = context.sortSocialFriends(friends)
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = groups
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,8 +174,7 @@ class SocialRootSection : Routes.Route() {
|
||||
},
|
||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||
),
|
||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
||||
}
|
||||
|
||||
// You can still set these for legacy use by submodules or scripts:
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.0").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("326").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
## v1.7.1
|
||||
- Auto-Open Engine ghost notification fix.
|
||||
- Continous Send notifiction bug fix.
|
||||
- Disappering chats fix.
|
||||
- Social page automatic selection bug fix.
|
||||
|
||||
## v1.7.0
|
||||
- Features:
|
||||
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
|
||||
- Implemented app intro showcase (tq ΞTΞRNAL)
|
||||
- Implemented "Spoof follower count" (tq <RSR/>)
|
||||
- Implemented "Spoof follower count" (tq RSR)
|
||||
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
|
||||
- Implemented "Chat Hold Kill" (tq C R E S T)
|
||||
- Implemented "Snapchat Purchase Date Spoof" (tq C R E S T)
|
||||
- Implemented Message log export for individual chat (tq schrodingerspet)
|
||||
- Implemented message icon indicator for Memories (tq <RSR/>)
|
||||
- Implemented "Chat Hold Kill" (tq SUJΛL)
|
||||
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
|
||||
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Implemented Memory message icon indicator(tq RSR)
|
||||
- Implemented two new message indicator toggles, for self snaps and group.
|
||||
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
|
||||
- Implemented new notification card for Continous send feature.
|
||||
|
||||
- Fixes:
|
||||
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq <RSR/>)
|
||||
- Media resend flow bug fixes. (tq schrodingerspet)
|
||||
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq schrodingerspet)
|
||||
- Media download support through message logger. (tq schrodingerspet)
|
||||
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
|
||||
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Snapchat Plus bug fixes to improve stability.
|
||||
- Spoof Device profile Backup/Restore bug fixes.
|
||||
- Redesgined manager app Logs filtering UI.
|
||||
|
||||
@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
} && getRuleState() != null
|
||||
}
|
||||
|
||||
fun canUseRule(conversationId: String): Boolean {
|
||||
open fun canUseRule(conversationId: String): Boolean {
|
||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (autoOpenConfig.globalState == false) return
|
||||
if (autoOpenConfig.globalState != true) return
|
||||
|
||||
restorePersistence()
|
||||
createNotificationChannels()
|
||||
@@ -486,9 +486,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
builder.setStyle(bigTextStyle)
|
||||
}
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||
}
|
||||
|
||||
private fun createPendingIntent(action: String): PendingIntent {
|
||||
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
|
||||
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
@@ -560,9 +559,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
.setContentTitle("Auto-Open")
|
||||
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||
}
|
||||
|
||||
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
|
||||
fun getInterface(): AutoOpenInterface {
|
||||
|
||||
@@ -70,14 +70,14 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||
private val queuedSplitItems = ArrayDeque<Any>()
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var queuedOverrideSnapDurationMs: Int? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
@@ -91,13 +91,13 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
queuedSplitCleanupItems.values.toList().forEach { item ->
|
||||
cleanup?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
queuedSplitCleanupItems.clear()
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
@@ -114,7 +114,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
queuedSplitCleanupItems[it.itemId] = it
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
@@ -130,8 +130,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
queuedSplitCleanupItems.remove(itemId)?.let { item ->
|
||||
cleanupItemHandler?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
@@ -159,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
val uri: String,
|
||||
val filePath: String? = null
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
@@ -309,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
return PreparedMediaItem(
|
||||
itemId = itemId,
|
||||
durationMs = durationMs,
|
||||
uri = uri.toString(),
|
||||
filePath = file.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
@@ -431,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
cleanupItemHandler = { uriString, filePath ->
|
||||
runCatching {
|
||||
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
|
||||
filePath?.let { path ->
|
||||
val file = File(path)
|
||||
if (file.exists()) file.delete()
|
||||
}
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
if (it.message?.contains("no access") == false) {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
|
||||
@@ -34,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.ModContext
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
@@ -88,6 +90,7 @@ class SendOverride : Feature("Send Override") {
|
||||
private var currentRecipientName: String = "Unknown"
|
||||
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
@@ -102,24 +105,93 @@ class SendOverride : Feature("Send Override") {
|
||||
queuedOriginalItemRepeatSnapDurationMs = null
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
private fun updateContinuousSendNotification(context: ModContext) {
|
||||
if (!engineActive.get()) return
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val remaining = queuedOriginalItemRepeatCount
|
||||
val processed = processedRepeatCount
|
||||
val total = totalRepeatCount
|
||||
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
|
||||
|
||||
if (!isWorking) {
|
||||
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
showCompletionNotification(context, 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)
|
||||
.setColor(0xFF3498DB.toInt())
|
||||
.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(context, ACTION_PAUSE_RESUME)).build())
|
||||
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
|
||||
val isError = sent < total && !isStopped.get()
|
||||
val title = when {
|
||||
isStopped.get() -> "Continuous Send Stopped"
|
||||
isError -> "Continuous Send Failed"
|
||||
else -> "Continuous Send Finished"
|
||||
}
|
||||
val content = "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(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
||||
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun createPendingIntent(context: ModContext, 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 handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
|
||||
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
|
||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
|
||||
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||
if (!result) {
|
||||
queuedOriginalItemRepeatCount++
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -136,7 +208,6 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
||||
private fun createContinuousNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -151,70 +222,6 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContinuousSendNotification() {
|
||||
if (!engineActive.get()) return
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val remaining = queuedOriginalItemRepeatCount
|
||||
val processed = processedRepeatCount
|
||||
val total = totalRepeatCount
|
||||
val isWorking = remaining > 0 && !isStopped.get() && engineActive.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 isError = sent < total && !isStopped.get()
|
||||
val title = when {
|
||||
isStopped.get() -> "Continuous Send Stopped"
|
||||
isError -> "Continuous Send Failed"
|
||||
else -> "Continuous Send Finished"
|
||||
}
|
||||
val content = "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(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
||||
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
||||
.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
|
||||
@@ -302,14 +309,14 @@ class SendOverride : Feature("Send Override") {
|
||||
when (intent?.action) {
|
||||
ACTION_PAUSE_RESUME -> {
|
||||
isPaused.set(!isPaused.get())
|
||||
updateContinuousSendNotification()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
isStopped.set(true)
|
||||
if (isPaused.get()) {
|
||||
isPaused.set(false)
|
||||
}
|
||||
updateContinuousSendNotification()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -858,10 +865,10 @@ class SendOverride : Feature("Send Override") {
|
||||
completionCallback: Any?
|
||||
): Boolean {
|
||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
||||
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
@@ -933,27 +940,54 @@ class SendOverride : Feature("Send Override") {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
if (isStopped.get()) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification()
|
||||
return
|
||||
}
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
context.coroutineScope.launch {
|
||||
while (isPaused.get() && !isStopped.get()) {
|
||||
delay(500)
|
||||
}
|
||||
if (isStopped.get()) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val callback = CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendIteration(index + 1)
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
if (index < repeatCount - 1) {
|
||||
sendIteration(index + 1)
|
||||
} else {
|
||||
// Batch Finished: Trigger original Snapchat callback
|
||||
originalCallback?.let { cb ->
|
||||
runCatching {
|
||||
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
|
||||
if (method != null) {
|
||||
if (method.parameterCount == 0) {
|
||||
method.invoke(cb)
|
||||
} else {
|
||||
// Pass null for all required parameters to safely trigger the completion UI
|
||||
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
|
||||
}
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
if (index > 0) delay(1000) // Human-like delay
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,7 +1014,7 @@ class SendOverride : Feature("Send Override") {
|
||||
context.runOnUiThread {
|
||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||
val handledRepeat = if (!handledSplit) {
|
||||
handleQueuedOriginalItemRepeatSuccess()
|
||||
handleQueuedOriginalItemRepeatSuccess(context)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -1008,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.runOnUiThread {
|
||||
val recipientNameForTask = recipientName
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
@@ -1564,7 +1597,6 @@ class SendOverride : Feature("Send Override") {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 1
|
||||
currentRecipientName = recipientNameForTask
|
||||
updateContinuousSendNotification()
|
||||
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
@@ -1572,12 +1604,13 @@ class SendOverride : Feature("Send Override") {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
} else {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 0
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
currentRecipientName = recipientNameForTask
|
||||
updateContinuousSendNotification()
|
||||
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
|
||||
@@ -347,6 +347,9 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val textureView = param.thisObject<TextureView>()
|
||||
runCatching {
|
||||
// Universal Guard: Only accelerate views owned by Snapchat.
|
||||
// This prevents crashes in native hardware providers across all devices.
|
||||
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
|
||||
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.7.0
|
||||
APP_VERSION_CODE=326
|
||||
APP_VERSION_NAME=1.7.1
|
||||
APP_VERSION_CODE=327
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user