Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c8042a93 | ||
|
|
141b5e16a0 |
@@ -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.5.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("294").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.2").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("296").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,3 +1,7 @@
|
||||
## v1.5.2
|
||||
- New: Continuous snap sender feature!
|
||||
- Fix: Snap send failure for E2E Chats
|
||||
|
||||
## v1.5.1
|
||||
- Fix: Splitting issue for video snaps sent through gallery media send override!
|
||||
- New: Toggle to turn off/on splitting for video snaps sent through send override
|
||||
|
||||
@@ -3328,6 +3328,12 @@
|
||||
"duration": "Duration: {duration}",
|
||||
"saveable_snap_hint": "Make Snap saveable in the chat",
|
||||
"single_send_hint": "Send as one snap",
|
||||
"continuous_send_toggle": "Continuous snap sender",
|
||||
"continuous_send_count_label": "Send count",
|
||||
"continuous_send_count_placeholder": "Enter number of sends",
|
||||
"continuous_send_hint": "This will send the same snap to the same recipient multiple times.",
|
||||
"continuous_send_invalid_count": "Enter a valid send count greater than 0",
|
||||
"continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.",
|
||||
"unlimited_duration": "Unlimited",
|
||||
"schedule": "Schedule",
|
||||
"select_time": "Select time",
|
||||
|
||||
@@ -400,6 +400,7 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
|
||||
val messageContent = event.messageContent
|
||||
val destinations = event.destinations
|
||||
if (messageContent.contentType != ContentType.CHAT) return@subscribe
|
||||
|
||||
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
|
||||
|
||||
@@ -431,10 +432,6 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
context.longToast(translation["encryption_failed_toast"])
|
||||
}
|
||||
}
|
||||
|
||||
if (event.messageContent.contentType == ContentType.SNAP) {
|
||||
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
@@ -80,6 +81,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
|
||||
fun setQueuedOverrideType(value: String?) {
|
||||
queuedOverrideType = value
|
||||
}
|
||||
@@ -97,6 +99,12 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
}
|
||||
fun sendReusableOriginalItem(): Boolean {
|
||||
val item = reusableOriginalItem ?: return false
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
|
||||
clearQueuedSplitItems(deleteTempItems = false)
|
||||
originalUnsplitItem = originalItem
|
||||
@@ -172,6 +180,10 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
val extractor = MediaExtractor()
|
||||
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
val trackMap = mutableMapOf<Int, Int>()
|
||||
val chunkStartUs = chunkStartMs * 1000
|
||||
val chunkEndUs = chunkEndMs * 1000
|
||||
var muxerStarted = false
|
||||
var wroteAnySample = false
|
||||
|
||||
try {
|
||||
extractor.setDataSource(inputFile.absolutePath)
|
||||
@@ -201,8 +213,9 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
|
||||
val bufferInfo = android.media.MediaCodec.BufferInfo()
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
extractor.seekTo(chunkStartMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
|
||||
while (true) {
|
||||
bufferInfo.offset = 0
|
||||
@@ -211,25 +224,36 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
|
||||
val sampleTimeUs = extractor.sampleTime
|
||||
if (sampleTimeUs < 0) break
|
||||
if (sampleTimeUs >= chunkEndMs * 1000) break
|
||||
if (sampleTimeUs < chunkStartUs) {
|
||||
extractor.advance()
|
||||
continue
|
||||
}
|
||||
if (sampleTimeUs >= chunkEndUs) break
|
||||
|
||||
val sampleTrackIndex = extractor.sampleTrackIndex
|
||||
val muxerTrackIndex = trackMap[sampleTrackIndex]
|
||||
if (muxerTrackIndex != null) {
|
||||
bufferInfo.presentationTimeUs = sampleTimeUs - (chunkStartMs * 1000)
|
||||
bufferInfo.presentationTimeUs = sampleTimeUs - chunkStartUs
|
||||
bufferInfo.flags = extractor.sampleFlags
|
||||
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
|
||||
wroteAnySample = true
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
outputFiles += outputFile
|
||||
if (wroteAnySample) {
|
||||
outputFiles += outputFile
|
||||
} else {
|
||||
outputFile.delete()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
outputFile.delete()
|
||||
outputFiles.forEach { it.delete() }
|
||||
throw throwable
|
||||
} finally {
|
||||
runCatching { muxer.stop() }
|
||||
if (muxerStarted) {
|
||||
runCatching { muxer.stop() }
|
||||
}
|
||||
runCatching { muxer.release() }
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
@@ -422,6 +446,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
|
||||
if (currentItems.isEmpty()) return@hookObjectMethod
|
||||
reusableOriginalItem = currentItems.firstOrNull()
|
||||
|
||||
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
|
||||
@@ -22,6 +22,9 @@ 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.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
@@ -50,6 +53,8 @@ import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Collections
|
||||
import java.util.IdentityHashMap
|
||||
import java.util.Locale
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
@@ -60,6 +65,40 @@ class SendOverride : Feature("Send Override") {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
|
||||
private val internalMultipartSend = ThreadLocal.withInitial { false }
|
||||
private var queuedOriginalItemRepeatCount = 0
|
||||
private var queuedOriginalItemRepeatOverrideType: String? = null
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
queuedOriginalItemRepeatOverrideType = overrideType
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
}
|
||||
|
||||
private fun clearQueuedOriginalItemRepeats() {
|
||||
queuedOriginalItemRepeatCount = 0
|
||||
queuedOriginalItemRepeatOverrideType = null
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
if (queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
return false
|
||||
}
|
||||
|
||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
return false
|
||||
}
|
||||
|
||||
queuedOriginalItemRepeatCount--
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||
if (!result) {
|
||||
queuedOriginalItemRepeatCount++
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedType by mutableStateOf("SNAP")
|
||||
@@ -405,6 +444,7 @@ class SendOverride : Feature("Send Override") {
|
||||
val recipientName = recipientNames.joinToString(", ")
|
||||
|
||||
event.canceled = true
|
||||
event.adapter.setResult(null)
|
||||
|
||||
fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) {
|
||||
val result = ev.adapter.invokeOriginal()
|
||||
@@ -438,6 +478,20 @@ class SendOverride : Feature("Send Override") {
|
||||
.first { it.name == "sendMessageWithContent" }
|
||||
}
|
||||
|
||||
val originalMessageJson = context.gson.toJson(localMessageContent.instanceNonNull())
|
||||
val originalCallback = event.adapter.args().getOrNull(2)
|
||||
val conversationManagerInstance by lazy {
|
||||
context.feature(Messaging::class).conversationManager?.instanceNonNull()
|
||||
}
|
||||
|
||||
fun invokeCallbackError(callback: Any?, error: Any?) {
|
||||
runCatching {
|
||||
callback?.javaClass?.methods?.firstOrNull { method ->
|
||||
method.name == "onError" && method.parameterCount == 1
|
||||
}?.invoke(callback, error)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyOverride(
|
||||
targetMessageContent: MessageContent,
|
||||
targetReader: ProtoReader,
|
||||
@@ -591,21 +645,98 @@ class SendOverride : Feature("Send Override") {
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
fun createMessageContentFromOriginal(): MessageContent {
|
||||
return MessageContent(
|
||||
context.gson.fromJson(originalMessageJson, context.classCache.localMessageContent)
|
||||
).also { messageContent ->
|
||||
val visited = Collections.newSetFromMap(IdentityHashMap<Any, Boolean>())
|
||||
|
||||
fun shouldScrubField(fieldName: String): Boolean {
|
||||
if (fieldName == "mId") return false
|
||||
return fieldName in setOf("mMessageId", "mQuotedMessageId") ||
|
||||
fieldName.contains("AttemptId", ignoreCase = true) ||
|
||||
fieldName.contains("ClientMessageId", ignoreCase = true) ||
|
||||
fieldName.contains("ClientId", ignoreCase = true) ||
|
||||
fieldName.contains("MessageUuid", ignoreCase = true) ||
|
||||
fieldName.contains("UUID", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun scrubValue(value: Any?) {
|
||||
if (value == null) return
|
||||
if (!visited.add(value)) return
|
||||
|
||||
when (value) {
|
||||
is String, is Number, is Boolean, is ByteArray, is Enum<*> -> return
|
||||
is Iterable<*> -> {
|
||||
value.forEach { scrubValue(it) }
|
||||
return
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
value.values.forEach { scrubValue(it) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sequence<Class<*>> {
|
||||
var current: Class<*>? = value.javaClass
|
||||
while (current != null && current != Any::class.java && current != Object::class.java) {
|
||||
yield(current)
|
||||
current = current.superclass
|
||||
}
|
||||
}.flatMap { it.declaredFields.asSequence() }
|
||||
.forEach { field ->
|
||||
runCatching {
|
||||
field.isAccessible = true
|
||||
if (shouldScrubField(field.name)) {
|
||||
when (field.type) {
|
||||
java.lang.Long.TYPE -> field.setLong(value, 0L)
|
||||
java.lang.Integer.TYPE -> field.setInt(value, 0)
|
||||
java.lang.Boolean.TYPE -> field.setBoolean(value, false)
|
||||
else -> field.set(value, null)
|
||||
}
|
||||
} else {
|
||||
scrubValue(field.get(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrubValue(messageContent.instanceNonNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun invokeSendManually(messageContent: MessageContent, callback: Any?) {
|
||||
val conversationManager = conversationManagerInstance ?: error("ConversationManager is null")
|
||||
internalMultipartSend.set(true)
|
||||
try {
|
||||
sendMessageWithContentMethod.invoke(
|
||||
conversationManager,
|
||||
cloneDestinations(event.destinations),
|
||||
messageContent.instanceNonNull(),
|
||||
callback
|
||||
)
|
||||
} finally {
|
||||
internalMultipartSend.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMediaManual(
|
||||
sourceMessageContent: MessageContent,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?,
|
||||
completionCallback: Any?
|
||||
): Boolean {
|
||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val originalJson = context.gson.toJson(localMessageContent.instanceNonNull())
|
||||
val originalCallback = event.adapter.args().getOrNull(2)
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
messageProtoReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
|
||||
fun buildPartMessageContent(partIndex: Int): MessageContent {
|
||||
val partContent = MessageContent(
|
||||
context.gson.fromJson(originalJson, context.classCache.localMessageContent)
|
||||
)
|
||||
val partContent = createMessageContentFromOriginal()
|
||||
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
|
||||
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
|
||||
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
@@ -637,62 +768,95 @@ class SendOverride : Feature("Send Override") {
|
||||
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
|
||||
|
||||
val callback = if (partIndex == mediaCount - 1) {
|
||||
originalCallback
|
||||
completionCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendPart(partIndex + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
runCatching {
|
||||
originalCallback?.javaClass?.methods?.firstOrNull { method ->
|
||||
method.name == "onError" && method.parameterCount == 1
|
||||
}?.invoke(originalCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
if (partIndex == 0) {
|
||||
event.adapter.setArg(1, partContent.instanceNonNull())
|
||||
event.adapter.setArg(2, callback)
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
internalMultipartSend.set(true)
|
||||
try {
|
||||
sendMessageWithContentMethod.invoke(
|
||||
context.feature(Messaging::class).conversationManager?.instanceNonNull(),
|
||||
cloneDestinations(event.destinations),
|
||||
partContent.instanceNonNull(),
|
||||
callback
|
||||
)
|
||||
} finally {
|
||||
internalMultipartSend.set(false)
|
||||
}
|
||||
}
|
||||
invokeSendManually(partContent, callback)
|
||||
}
|
||||
|
||||
sendPart(0)
|
||||
return true
|
||||
}
|
||||
|
||||
postSavePolicy = null
|
||||
val targetReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false
|
||||
invokeSendManually(sourceMessageContent, completionCallback)
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendRepeatedMediaManual(
|
||||
repeatCount: Int,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?
|
||||
): Boolean {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendIteration(index + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
}
|
||||
}
|
||||
|
||||
sendIteration(0)
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
postSavePolicy = null
|
||||
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
?: configOverrideType?.takeIf { it != "always_ask" }
|
||||
if (resolvedOverrideType != null) {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null) {
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
|
||||
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
|
||||
sendEvent.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||
val handledRepeat = if (!handledSplit) {
|
||||
handleQueuedOriginalItemRepeatSuccess()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (!handledSplit && !handledRepeat) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
}
|
||||
event.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
sendEvent.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedOverrideType != null) {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
@@ -702,7 +866,7 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.runOnUiThread {
|
||||
val recipientNameForTask = recipientName
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
@@ -792,6 +956,8 @@ class SendOverride : Feature("Send Override") {
|
||||
context.translation.getCategory("features.options.gallery_media_send_override")
|
||||
}
|
||||
var scheduleEnabled by remember { mutableStateOf(false) }
|
||||
var continuousSendEnabled by remember { mutableStateOf(false) }
|
||||
var continuousSendCount by remember { mutableStateOf("2") }
|
||||
|
||||
Text(
|
||||
fontSize = 20.sp,
|
||||
@@ -907,6 +1073,42 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
continuousSendEnabled = !continuousSendEnabled
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = continuousSendEnabled,
|
||||
onCheckedChange = {
|
||||
continuousSendEnabled = it
|
||||
}
|
||||
)
|
||||
Text(text = mainTranslation["continuous_send_toggle"], lineHeight = 15.sp)
|
||||
}
|
||||
|
||||
if (continuousSendEnabled) {
|
||||
OutlinedTextField(
|
||||
value = continuousSendCount,
|
||||
onValueChange = { value ->
|
||||
continuousSendCount = value.filter(Char::isDigit).take(3)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(mainTranslation["continuous_send_count_label"]) },
|
||||
placeholder = { Text(mainTranslation["continuous_send_count_placeholder"]) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions.Default
|
||||
)
|
||||
Text(
|
||||
text = mainTranslation["continuous_send_hint"],
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.72f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
@@ -1071,8 +1273,27 @@ class SendOverride : Feature("Send Override") {
|
||||
Text(context.translation["button.cancel"])
|
||||
}
|
||||
Button(onClick = {
|
||||
alertDialog.dismiss()
|
||||
val finalSelectedType = selectedType
|
||||
val repeatCount = if (continuousSendEnabled) {
|
||||
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
|
||||
} else {
|
||||
1
|
||||
}
|
||||
if (repeatCount == null) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
text = mainTranslation["continuous_send_invalid_count"]
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
if (repeatCount > 1 && disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
text = mainTranslation["continuous_send_single_send_conflict"]
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
|
||||
@@ -1137,10 +1358,11 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
|
||||
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (event.canceled) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
if (sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
)) {
|
||||
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
@@ -1187,10 +1409,24 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (event.canceled) {
|
||||
if (repeatCount == 1) {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
} else if (MediaFilePicker.hasReusableOriginalItem()) {
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
} else {
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.5.1
|
||||
APP_VERSION_CODE=294
|
||||
APP_VERSION_NAME=1.5.2
|
||||
APP_VERSION_CODE=296
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user