Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f98221e3 | ||
|
|
dbbc52b67f | ||
|
|
b7c8042a93 | ||
|
|
141b5e16a0 | ||
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
@@ -53,8 +53,6 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -78,6 +76,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.*
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.DialogProperties
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.max
|
||||
|
||||
@@ -126,7 +126,7 @@ class BetterLocationRoot : Routes.Route() {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = context.translation.format(
|
||||
text = translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to friendLocation.latitude.toFloat().toString(),
|
||||
"longitude" to friendLocation.longitude.toFloat().toString()
|
||||
|
||||
@@ -466,6 +466,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
var changelogLoading by remember { mutableStateOf(false) }
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
@@ -531,6 +535,31 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog() {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
body.trim()
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogText = text
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogError = e.message ?: "Failed to fetch"
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
|
||||
@@ -626,7 +655,8 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
@@ -634,6 +664,12 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
|
||||
haptic = haptic
|
||||
) { showFullChangelogDialog = true; loadFullChangelog() }
|
||||
}
|
||||
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
|
||||
Row(
|
||||
@@ -778,6 +814,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
text = "", icon = Icons.Filled.Notifications,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -796,6 +833,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -806,6 +844,28 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
if (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
|
||||
@@ -97,6 +97,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
|
||||
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
|
||||
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
@@ -110,13 +117,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
// The "Structured Glass" Container (1:1 with build 33a7e8f)
|
||||
// The "Structured Glass" Container (Dynamically Morphed)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 12.dp),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
.padding(top = containerTopPadding),
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
@@ -128,7 +135,7 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = controlsHeight,
|
||||
top = controlsHeight - 44.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
||||
@@ -345,6 +345,10 @@ object LegacyTheme : ThemeContract {
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogText by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showFullChangelogDialog by remember { mutableStateOf(false) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError by remember { mutableStateOf<String?>(null) }
|
||||
var fullChangelogText by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by remember { mutableStateOf(false) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
var announcementsError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -415,6 +419,23 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog(url: String) {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true; fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
|
||||
response.body?.string()?.trim() ?: throw IllegalStateException("Empty changelog body")
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) { fullChangelogText = text; fullChangelogLoading = false }
|
||||
}.onFailure { error ->
|
||||
withContext(Dispatchers.Main) { fullChangelogError = error.message ?: "Failed to load changelog"; fullChangelogLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
|
||||
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
|
||||
@@ -450,6 +471,9 @@ object LegacyTheme : ThemeContract {
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) {
|
||||
showAnnouncementsDialog = true; loadAnnouncements()
|
||||
}
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") {
|
||||
showFullChangelogDialog = true; loadFullChangelog(changelogUrl)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
LocalHomeActionChips()
|
||||
@@ -570,6 +594,7 @@ object LegacyTheme : ThemeContract {
|
||||
onConfirm = { showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -587,6 +612,7 @@ object LegacyTheme : ThemeContract {
|
||||
text = "", icon = Icons.Filled.Notifications,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -597,6 +623,24 @@ object LegacyTheme : ThemeContract {
|
||||
)
|
||||
}
|
||||
|
||||
if (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "", icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
|
||||
@@ -40,7 +40,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog as StandardDialog
|
||||
import androidx.core.net.toUri
|
||||
import com.github.skydoves.colorpicker.compose.*
|
||||
import com.google.gson.JsonParser
|
||||
@@ -67,6 +66,7 @@ import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.File
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
|
||||
|
||||
|
||||
class AlertDialogs(
|
||||
@@ -1093,7 +1093,7 @@ class AlertDialogs(
|
||||
val lat = remember { mutableStateOf(coordinates.first.toString()) }
|
||||
val lon = remember { mutableStateOf(coordinates.second.toString()) }
|
||||
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = {
|
||||
customCoordinatesDialog = false
|
||||
},
|
||||
@@ -1398,7 +1398,7 @@ class AlertDialogs(
|
||||
|
||||
// Add/Edit message dialog
|
||||
if (showAddDialog) {
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
|
||||
@@ -4,12 +4,28 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Outline
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.*
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.activity.addCallback
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
@@ -19,9 +35,12 @@ import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.semantics.dialog
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
@@ -33,6 +52,12 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext?.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
class DialogProperties constructor(
|
||||
val dismissOnBackPress: Boolean = true,
|
||||
val dismissOnClickOutside: Boolean = true,
|
||||
@@ -83,6 +108,17 @@ fun Dialog(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
var forceInline by remember(view) { mutableStateOf(false) }
|
||||
val hostActivity = remember(view) { view.context.findActivity() }
|
||||
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
|
||||
if (shouldUseInline) {
|
||||
InlineDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
content = content
|
||||
)
|
||||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val composition = rememberCompositionContext()
|
||||
@@ -110,11 +146,30 @@ fun Dialog(
|
||||
}
|
||||
|
||||
DisposableEffect(dialog) {
|
||||
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
|
||||
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
|
||||
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
val showDialog = {
|
||||
try {
|
||||
dialog.prepareWindowForHost()
|
||||
if (!dialog.isShowing) {
|
||||
dialog.show()
|
||||
}
|
||||
} catch (_: WindowManager.BadTokenException) {
|
||||
forceInline = true
|
||||
}
|
||||
}
|
||||
|
||||
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
|
||||
showDialog()
|
||||
} else {
|
||||
val listener = object : OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
v.removeOnAttachStateChangeListener(this)
|
||||
showDialog()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) = Unit
|
||||
}
|
||||
view.addOnAttachStateChangeListener(listener)
|
||||
}
|
||||
dialog.show()
|
||||
|
||||
onDispose {
|
||||
dialog.dismiss()
|
||||
@@ -131,6 +186,75 @@ fun Dialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissOnClickOutside: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val displayMetrics = LocalContext.current.resources.displayMetrics
|
||||
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
|
||||
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = androidx.compose.ui.Alignment.Center,
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = dismissOnClickOutside
|
||||
),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(screenWidthDp)
|
||||
.height(screenHeightDp)
|
||||
.then(
|
||||
if (dismissOnClickOutside) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onDismissRequest
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.semantics { dialog() },
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
|
||||
targetScale = 0.96f,
|
||||
animationSpec = tween(120)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogWindowProvider {
|
||||
val window: Window
|
||||
}
|
||||
@@ -279,6 +403,26 @@ private class DialogWrapper(
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareWindowForHost() {
|
||||
val hostActivity = composeView.context.findActivity()
|
||||
val hostToken = composeView.applicationWindowToken
|
||||
?: composeView.windowToken
|
||||
?: composeView.rootView?.applicationWindowToken
|
||||
?: composeView.rootView?.windowToken
|
||||
if (hostActivity == null) {
|
||||
when {
|
||||
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
|
||||
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
}
|
||||
}
|
||||
if (hostToken != null) {
|
||||
window?.attributes = window?.attributes?.apply {
|
||||
token = hostToken
|
||||
}
|
||||
}
|
||||
hostActivity?.let { setOwnerActivity(it) }
|
||||
}
|
||||
|
||||
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
|
||||
dialogLayout.layoutDirection = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
||||
|
||||
@@ -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.0").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("292").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.4").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("300").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,23 @@
|
||||
## v1.5.4
|
||||
- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap
|
||||
- Fix: Spoof Coordinates Title
|
||||
- New: Changelogs feature
|
||||
|
||||
## v1.5.3
|
||||
- New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue)
|
||||
- Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay
|
||||
- New: Translucent Dialogs & refreshed animation
|
||||
- Fix: Adjusted Snap Preview Location
|
||||
|
||||
## 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
|
||||
- Fix: Aphelion task page layout optimization(tq to Kaladin)
|
||||
|
||||
## v1.5.0
|
||||
- Fix: Skip when marking as seen for newer versions of Snapchat
|
||||
- New: Hide Conversation Toolbox UI
|
||||
|
||||
@@ -1190,6 +1190,14 @@
|
||||
"name": "Mark Snap as Seen Button",
|
||||
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"name": "Mark Snaps as Seen Mode",
|
||||
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
|
||||
},
|
||||
"mark_snap_as_seen_limit": {
|
||||
"name": "Mark Snaps as Seen Limit",
|
||||
"description": "How many snaps to process per run when the mode is set to limit"
|
||||
},
|
||||
"skip_when_marking_as_seen": {
|
||||
"name": "Skip When Marking as Seen",
|
||||
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
|
||||
@@ -3639,4 +3647,4 @@
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,6 +1254,14 @@
|
||||
"name": "Mark Snap as Seen Button",
|
||||
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"name": "Mark Snaps as Seen Mode",
|
||||
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
|
||||
},
|
||||
"mark_snap_as_seen_limit": {
|
||||
"name": "Mark Snaps as Seen Limit",
|
||||
"description": "How many snaps to process per run when the mode is set to limit"
|
||||
},
|
||||
"skip_when_marking_as_seen": {
|
||||
"name": "Skip When Marking as Seen",
|
||||
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
|
||||
@@ -2514,6 +2522,10 @@
|
||||
"remove_audio_note_duration": "Remove Audio Note Duration",
|
||||
"remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"limit": "Limit Per Run",
|
||||
"complete": "Complete Queue"
|
||||
},
|
||||
"hide_ui_components": {
|
||||
"hide_profile_call_buttons": "Remove Profile Call Buttons",
|
||||
"hide_chat_call_buttons": "Remove Chat Call Buttons",
|
||||
@@ -3327,6 +3339,13 @@
|
||||
"title": "Send media as",
|
||||
"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",
|
||||
@@ -3430,10 +3449,10 @@
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
},
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"search_by": "Search by",
|
||||
"since": "Since",
|
||||
"until": "Until",
|
||||
@@ -3448,7 +3467,7 @@
|
||||
"started_typing": "Started typing",
|
||||
"stopped_typing": "Stopped typing",
|
||||
"started_speaking": "Started speaking",
|
||||
"stopped_speaking": "Stopped speaking",
|
||||
"stopped_speaking": "Stopped speaking",
|
||||
"started_peeking": "Started peeking",
|
||||
"stopped_peeking": "Stopped peeking",
|
||||
"message_read": "Read message",
|
||||
|
||||
@@ -205,6 +205,12 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
|
||||
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
|
||||
val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() }
|
||||
val markSnapAsSeenProcessingMode = unique("mark_snap_as_seen_processing_mode", "limit", "complete").apply {
|
||||
set("limit")
|
||||
}
|
||||
val markSnapAsSeenLimit = integer("mark_snap_as_seen_limit", defaultValue = 50) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() }
|
||||
val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() }
|
||||
val disableReplayInFF = boolean("disable_replay_in_ff")
|
||||
|
||||
@@ -113,6 +113,18 @@ class BulkMessagingAction : AbstractAction() {
|
||||
private val translation by lazy { context.translation.getCategory("bulk_messaging_action") }
|
||||
private val betterLocation by lazy { context.feature(BetterLocation::class) }
|
||||
|
||||
private fun hasReliableStreak(friend: FriendInfo, streakFeedUserIds: Set<String>): Boolean {
|
||||
val userId = friend.userId ?: return false
|
||||
if (userId in streakFeedUserIds) return true
|
||||
if (friend.streakExpirationTimestamp > 0L) return true
|
||||
if (friend.streakLength > 0) return true
|
||||
val categories = friend.friendmojiCategories?.split(",") ?: return false
|
||||
return categories.any { category ->
|
||||
category.contains("streak", ignoreCase = true) ||
|
||||
category.contains("hourglass", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private object BulkMessagingPalette {
|
||||
val background = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -286,7 +298,12 @@ class BulkMessagingAction : AbstractAction() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
|
||||
private fun filterFriends(
|
||||
friends: List<FriendInfo>,
|
||||
filter: Filter,
|
||||
nameFilter: String,
|
||||
streakFeedUserIds: Set<String> = emptySet()
|
||||
): List<FriendInfo> {
|
||||
val userIdBlacklist = arrayOf(
|
||||
context.database.myUserId,
|
||||
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai
|
||||
@@ -310,8 +327,12 @@ class BulkMessagingAction : AbstractAction() {
|
||||
Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value
|
||||
Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value
|
||||
Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0
|
||||
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0
|
||||
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0
|
||||
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
|
||||
friend.addedTimestamp > 0 &&
|
||||
hasReliableStreak(friend, streakFeedUserIds)
|
||||
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
|
||||
friend.addedTimestamp > 0 &&
|
||||
!hasReliableStreak(friend, streakFeedUserIds)
|
||||
Filter.FOLLOWING -> {
|
||||
val isFollowing = friend.friendLinkType == FriendLinkType.FOLLOWING.value ||
|
||||
(friend.friendLinkType == FriendLinkType.OUTGOING.value &&
|
||||
@@ -390,10 +411,21 @@ class BulkMessagingAction : AbstractAction() {
|
||||
val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) {
|
||||
runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() }
|
||||
} else emptySet()
|
||||
val streakFeedUserIds = if (filter == Filter.STREAKS || filter == Filter.NON_STREAKS) {
|
||||
runCatching {
|
||||
context.database.getFeedEntries(Int.MAX_VALUE)
|
||||
.filter { it.conversationType == 0 && it.participantsSize == 2 }
|
||||
.filter { (it.streakCount ?: 0) > 0 || (it.streakExpirationTimestampMs ?: 0L) > 0L }
|
||||
.mapNotNull { entry ->
|
||||
entry.friendUserId ?: entry.participants?.firstOrNull { id -> id != context.database.myUserId }
|
||||
}
|
||||
.toSet()
|
||||
}.getOrElse { emptySet() }
|
||||
} else emptySet()
|
||||
|
||||
val newFriends = if (conversationType == ConversationType.FRIENDS_ONLY || conversationType == ConversationType.BOTH) {
|
||||
context.database.getAllFriends().let { friends ->
|
||||
filterFriends(friends, filter, nameFilter)
|
||||
filterFriends(friends, filter, nameFilter, streakFeedUserIds)
|
||||
}
|
||||
.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }
|
||||
.filter { friend ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
@@ -36,6 +42,7 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
@@ -47,17 +54,308 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.lang.reflect.Method
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
|
||||
class MediaFilePicker : Feature("Media File Picker") {
|
||||
companion object {
|
||||
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 var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
|
||||
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
|
||||
}
|
||||
fun getQueuedOverrideType(): String? = queuedOverrideType
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
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
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
val item = originalUnsplitItem ?: return false
|
||||
val overrideType = queuedOverrideType
|
||||
clearQueuedSplitItems(deleteTempItems = true)
|
||||
queuedOverrideType = overrideType
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val next = queuedSplitItems.removeFirstOrNull() ?: run {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
val result = sender(next)
|
||||
if (!result) {
|
||||
queuedSplitItems.addFirst(next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var lastMediaDuration: Long? = null
|
||||
private set
|
||||
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
inputFile: File,
|
||||
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
|
||||
): List<File> {
|
||||
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
|
||||
if (durationMs <= chunkDurationMs) return listOf(inputFile)
|
||||
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val rotation = runCatching {
|
||||
retriever.setDataSource(inputFile.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
||||
}.getOrDefault(0).also {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
|
||||
val outputFiles = mutableListOf<File>()
|
||||
var chunkStartMs = 0L
|
||||
var chunkIndex = 0
|
||||
|
||||
while (chunkStartMs < durationMs) {
|
||||
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
|
||||
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
|
||||
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)
|
||||
|
||||
repeat(extractor.trackCount) { trackIndex ->
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
|
||||
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
|
||||
extractor.selectTrack(trackIndex)
|
||||
trackMap[trackIndex] = muxer.addTrack(format)
|
||||
}
|
||||
|
||||
if (rotation != 0) {
|
||||
muxer.setOrientationHint(rotation)
|
||||
}
|
||||
|
||||
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
|
||||
extractor.getTrackFormat(trackIndex).let { format ->
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
1024 * 1024
|
||||
}
|
||||
}
|
||||
} ?: (1024 * 1024)
|
||||
|
||||
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
|
||||
val bufferInfo = android.media.MediaCodec.BufferInfo()
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
|
||||
while (true) {
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = extractor.readSampleData(buffer, 0)
|
||||
if (bufferInfo.size < 0) break
|
||||
|
||||
val sampleTimeUs = extractor.sampleTime
|
||||
if (sampleTimeUs < 0) 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 - chunkStartUs
|
||||
bufferInfo.flags = extractor.sampleFlags
|
||||
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
|
||||
wroteAnySample = true
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
if (wroteAnySample) {
|
||||
outputFiles += outputFile
|
||||
} else {
|
||||
outputFile.delete()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
outputFile.delete()
|
||||
outputFiles.forEach { it.delete() }
|
||||
throw throwable
|
||||
} finally {
|
||||
if (muxerStarted) {
|
||||
runCatching { muxer.stop() }
|
||||
}
|
||||
runCatching { muxer.release() }
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
|
||||
chunkStartMs += chunkDurationMs
|
||||
chunkIndex++
|
||||
}
|
||||
|
||||
return outputFiles
|
||||
}
|
||||
|
||||
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
|
||||
val resolver = context.androidContext.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("Failed to create MediaStore entry")
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("Failed to open MediaStore output stream")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
}
|
||||
}.onFailure {
|
||||
resolver.delete(uri, null, null)
|
||||
throw it
|
||||
}
|
||||
|
||||
val durationMs = extractMediaDuration(uri) ?: 0L
|
||||
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
|
||||
|
||||
context.coroutineScope.launch {
|
||||
delay(120_000)
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
return mediaItems.mapIndexedNotNull { index, mediaItem ->
|
||||
itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", mediaItem.durationMs.toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", mediaItem.itemId)
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", index.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
|
||||
val numericId = itemId.toLongOrNull() ?: return null
|
||||
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
|
||||
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
) ?: return null
|
||||
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
|
||||
|
||||
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
|
||||
|
||||
return runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
sourceFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Failed to open source gallery video")
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
chunkFiles.forEach { if (it != sourceFile) it.delete() }
|
||||
preparedItems
|
||||
}.also {
|
||||
sourceFile.delete()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to prepare split gallery items", it)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractMediaDuration(uri: Uri): Long? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return runCatching {
|
||||
@@ -96,6 +394,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
var sendItemsMethod: Method? = null
|
||||
var drawerViewClass: Class<*>? = null
|
||||
var sendItemsListItemClassFallback: Class<*>? = null
|
||||
var sendItemsHookedHandler: Any? = null
|
||||
|
||||
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
|
||||
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
|
||||
@@ -115,6 +414,72 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
sendItemsMethod = sendItems
|
||||
handlerParamMethod.hook(HookStage.AFTER) {
|
||||
chatMediaDrawerActionHandler = it.arg(0)
|
||||
val handlerInstance = chatMediaDrawerActionHandler
|
||||
sendSingleItemHandler = sendSingleItem@{ item ->
|
||||
runCatching {
|
||||
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
true
|
||||
}.getOrElse { throwable ->
|
||||
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
sendItemsHookedHandler = handlerInstance
|
||||
|
||||
Hooker.hookObjectMethod(
|
||||
handlerInstance::class.java,
|
||||
handlerInstance,
|
||||
sendItemsName,
|
||||
HookStage.BEFORE
|
||||
) { param ->
|
||||
if (bypassSplitOnce) {
|
||||
bypassSplitOnce = false
|
||||
return@hookObjectMethod
|
||||
}
|
||||
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
|
||||
?: currentItems.firstOrNull()?.javaClass
|
||||
?: return@hookObjectMethod
|
||||
|
||||
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
|
||||
var didExpand = false
|
||||
val expandedItems = currentItems.flatMap { item ->
|
||||
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
|
||||
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
|
||||
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
|
||||
?: 0L
|
||||
val itemId = baseItem.getObjectFieldOrNull("_itemId")
|
||||
?.getObjectFieldOrNull("_itemId")
|
||||
?.toString()
|
||||
?: return@flatMap listOf(item)
|
||||
|
||||
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
|
||||
if (splitItems.isNullOrEmpty()) {
|
||||
listOf(item)
|
||||
} else {
|
||||
didExpand = true
|
||||
preparedExpandedItems.addAll(splitItems)
|
||||
buildDrawerItems(itemClass, splitItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (didExpand && expandedItems.isNotEmpty()) {
|
||||
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
|
||||
param.setArg(1, listOf(expandedItems.first()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +536,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
fun sendMedia() {
|
||||
fun sendMedia(items: List<PreparedMediaItem>? = null) {
|
||||
val method = sendItemsMethod ?: return
|
||||
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
@@ -180,27 +545,13 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
|
||||
return
|
||||
}
|
||||
val item = itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", System.currentTimeMillis().toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", firstVideoId.toString())
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", 0.0)
|
||||
} ?: run {
|
||||
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
|
||||
val builtItems = buildDrawerItems(itemClass, mediaItems)
|
||||
if (builtItems.size != mediaItems.size) {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
|
||||
return
|
||||
}
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
|
||||
}
|
||||
|
||||
fun startConversion(audioOnly: Boolean) {
|
||||
@@ -238,8 +589,25 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
|
||||
|
||||
runCatching {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
|
||||
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
|
||||
convertedFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(convertedFile)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
|
||||
chunkFiles.forEach { if (it != convertedFile) it.delete() }
|
||||
convertedFile.delete()
|
||||
|
||||
sendMedia(preparedItems)
|
||||
} else {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
}
|
||||
}.onFailure {
|
||||
mediaInputStream = null
|
||||
context.log.error(it)
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -39,6 +40,54 @@ import kotlin.random.Random
|
||||
|
||||
class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") }
|
||||
private val markAsSeenBatchSize = 50
|
||||
private val markAsSeenBatchCooldownMs = 4000L
|
||||
|
||||
private data class PendingSnapMessage(
|
||||
val clientMessageId: Long,
|
||||
val creationTimestamp: Long
|
||||
)
|
||||
|
||||
private fun String?.isRateLimited(): Boolean {
|
||||
val value = this ?: return false
|
||||
return value.contains("RESOURCE_EXHAUSTED", ignoreCase = true) ||
|
||||
value.contains("Rate limited", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun showRateLimitedDialog(processed: Int, total: Int) {
|
||||
val activity = context.mainActivity ?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
"Rate limited after $processed/$total snaps. Try again later."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createComposeAlertDialog(activity) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
title = "Rate Limited",
|
||||
subtitle = "Snapchat stopped the mark-as-seen run to protect your account",
|
||||
icon = Icons.Default.WarningAmber
|
||||
) {
|
||||
Column(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Processed $processed of $total snaps before the request was rate limited.",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = "No bypass was attempted. Wait a bit and run it again, or lower the per-run limit in settings.",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun markConversationsAsRead(conversationIds: List<String>) {
|
||||
conversationIds.forEach { conversationId ->
|
||||
@@ -63,18 +112,66 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingSnapMessageIds(conversationId: String, requestedLimit: Int?): List<Long> {
|
||||
val collected = mutableListOf<PendingSnapMessage>()
|
||||
val pageSize = 200
|
||||
var page = 0
|
||||
|
||||
while (true) {
|
||||
val messages = context.database.getMessagesFromConversationId(conversationId, pageSize, page) ?: break
|
||||
messages.forEach { message ->
|
||||
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@forEach
|
||||
if (message.isViewedByUser == 1 || message.readTimestamp > 0L) return@forEach
|
||||
collected += PendingSnapMessage(
|
||||
clientMessageId = message.clientMessageId.toLong(),
|
||||
creationTimestamp = message.creationTimestamp
|
||||
)
|
||||
}
|
||||
if (messages.size < pageSize) break
|
||||
if (requestedLimit != null && collected.size >= requestedLimit) break
|
||||
page++
|
||||
}
|
||||
|
||||
return collected
|
||||
.distinctBy { it.clientMessageId }
|
||||
.sortedBy { it.creationTimestamp }
|
||||
.let { pending ->
|
||||
if (requestedLimit != null) pending.take(requestedLimit) else pending
|
||||
}
|
||||
.map { it.clientMessageId }
|
||||
}
|
||||
|
||||
fun markSnapsAsSeen(conversationId: String) {
|
||||
val messaging = context.feature(Messaging::class)
|
||||
val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
context.translation["mark_as_seen.no_unseen_snaps_toast"]
|
||||
)
|
||||
return
|
||||
val processingMode = context.config.messaging.markSnapAsSeenProcessingMode.get()
|
||||
val configuredLimit = context.config.messaging.markSnapAsSeenLimit.get()
|
||||
.coerceAtLeast(1)
|
||||
val requestedLimit = if (processingMode == "complete") null else configuredLimit
|
||||
val messageIds = getPendingSnapMessageIds(conversationId, requestedLimit)
|
||||
.ifEmpty {
|
||||
messaging.getFeedCachedMessageIds(conversationId)
|
||||
?.map { it.toLong() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { cached ->
|
||||
if (requestedLimit != null) cached.take(requestedLimit) else cached
|
||||
}
|
||||
?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
context.translation["mark_as_seen.no_unseen_snaps_toast"]
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
val targetMessageIds = if (requestedLimit == null) {
|
||||
messageIds
|
||||
} else {
|
||||
messageIds.take(requestedLimit)
|
||||
}
|
||||
|
||||
var job: Job? = null
|
||||
val processedCount = mutableIntStateOf(0)
|
||||
var rateLimitedAt: Int? = null
|
||||
val dialog = createComposeAlertDialog(context.mainActivity!!, builder = {
|
||||
setOnDismissListener { job?.cancel() }
|
||||
}) {
|
||||
@@ -95,7 +192,7 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
trackColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
)
|
||||
Text(
|
||||
text = "${processedCount.intValue}/${messageIds.size}",
|
||||
text = "${processedCount.intValue}/${targetMessageIds.size}",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
@@ -104,16 +201,34 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
}.apply { show() }
|
||||
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
messageIds.forEachIndexed { index, messageId ->
|
||||
markSnapAsSeen(conversationId, messageId)
|
||||
targetMessageIds.forEachIndexed { index, messageId ->
|
||||
val result = markSnapAsSeen(conversationId, messageId)
|
||||
if (result.isRateLimited()) {
|
||||
rateLimitedAt = processedCount.intValue
|
||||
return@launch
|
||||
}
|
||||
delay(Random.nextLong(20, 60))
|
||||
context.runOnUiThread {
|
||||
processedCount.intValue = index + 1
|
||||
}
|
||||
val processed = index + 1
|
||||
if (processed < targetMessageIds.size && processed % markAsSeenBatchSize == 0) {
|
||||
delay(markAsSeenBatchCooldownMs)
|
||||
}
|
||||
}
|
||||
}.also { job = it }.invokeOnCompletion {
|
||||
context.runOnUiThread {
|
||||
dialog.dismiss()
|
||||
if (rateLimitedAt != null) {
|
||||
val processedIndex = rateLimitedAt!!
|
||||
processedCount.intValue = processedIndex
|
||||
showRateLimitedDialog(processedIndex, targetMessageIds.size)
|
||||
} else if (requestedLimit != null && targetMessageIds.size < messageIds.size) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
"Processed ${targetMessageIds.size} of ${messageIds.size} unseen snaps."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -32,19 +35,26 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
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
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
|
||||
import me.eternal.purrfectsnap.core.messaging.MessageSender
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import me.eternal.purrfectsnap.core.util.CallbackBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
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
|
||||
@@ -54,9 +64,45 @@ import kotlin.time.toDuration
|
||||
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")
|
||||
private var disableSplitForCurrentSend by mutableStateOf(false)
|
||||
private var customDuration by mutableFloatStateOf(10f)
|
||||
private var scheduledTime by mutableStateOf<Long?>(null)
|
||||
private var showClockPicker by mutableStateOf(false)
|
||||
@@ -66,7 +112,6 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
|
||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -362,7 +407,12 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
|
||||
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
|
||||
}
|
||||
|
||||
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
|
||||
if (internalMultipartSend.get() == true) return@subscribe
|
||||
postSavePolicy = null
|
||||
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
|
||||
val localMessageContent = event.messageContent
|
||||
@@ -394,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()
|
||||
@@ -401,9 +452,54 @@ class SendOverride : Feature("Send Override") {
|
||||
ev.canceled = false
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
val sendMessageCallbackClass by lazy {
|
||||
lateinit var result: Class<*>
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fun cloneDestinations(source: MessageDestinations): Any {
|
||||
return context.gson.fromJson(
|
||||
context.gson.toJson(source.instanceNonNull()),
|
||||
context.classCache.messageDestinations
|
||||
)
|
||||
}
|
||||
|
||||
val sendMessageWithContentMethod by lazy {
|
||||
sequence {
|
||||
var current: Class<*>? = context.classCache.conversationManager
|
||||
while (current != null && current != Any::class.java && current != Object::class.java) {
|
||||
yield(current)
|
||||
current = current.superclass
|
||||
}
|
||||
}.flatMap { it.declaredMethods.asSequence() }
|
||||
.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,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?
|
||||
): Boolean {
|
||||
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
context.translation["gallery_media_send_override.multiple_media_toast"]
|
||||
@@ -416,10 +512,10 @@ class SendOverride : Feature("Send Override") {
|
||||
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
|
||||
postSavePolicy = savePolicyValue
|
||||
|
||||
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
|
||||
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
|
||||
|
||||
if (localMessageContent.contentType != ContentType.SNAP) {
|
||||
localMessageContent.content = ProtoWriter().apply {
|
||||
if (targetMessageContent.contentType != ContentType.SNAP) {
|
||||
targetMessageContent.content = ProtoWriter().apply {
|
||||
from(11) {
|
||||
from(5) {
|
||||
from(1) {
|
||||
@@ -440,11 +536,11 @@ class SendOverride : Feature("Send Override") {
|
||||
}.toByteArray()
|
||||
}
|
||||
|
||||
localMessageContent.contentType = ContentType.SNAP
|
||||
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
|
||||
targetMessageContent.contentType = ContentType.SNAP
|
||||
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
|
||||
edit(11, 5, 2) {
|
||||
arrayOf(6, 7, 8).forEach { remove(it) }
|
||||
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
|
||||
addVarInt(8, snapDurationMs / 1000)
|
||||
if (snapDurationMs / 1000 <= 0) {
|
||||
@@ -474,11 +570,11 @@ class SendOverride : Feature("Send Override") {
|
||||
if (shouldPreventSave) {
|
||||
postSavePolicy = 1 // PROHIBITED
|
||||
}
|
||||
localMessageContent.contentType = ContentType.NOTE
|
||||
targetMessageContent.contentType = ContentType.NOTE
|
||||
val stripMeta = context.config.messaging.stripMediaMetadata.get()
|
||||
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
|
||||
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
|
||||
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
|
||||
val audioNoteProto = MessageSender.audioNoteProto(
|
||||
@@ -487,7 +583,7 @@ class SendOverride : Feature("Send Override") {
|
||||
)
|
||||
|
||||
// Set save policy in the proto if prevent audio is enabled
|
||||
localMessageContent.content = if (shouldPreventSave) {
|
||||
targetMessageContent.content = if (shouldPreventSave) {
|
||||
// Check which path structure exists in the audio note proto
|
||||
val protoReader = ProtoReader(audioNoteProto)
|
||||
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
|
||||
@@ -519,7 +615,7 @@ class SendOverride : Feature("Send Override") {
|
||||
Class.forName(
|
||||
"com.snapchat.client.messaging.SavePolicy",
|
||||
false,
|
||||
localMessageContent.instanceNonNull().javaClass.classLoader
|
||||
targetMessageContent.instanceNonNull().javaClass.classLoader
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -537,7 +633,7 @@ class SendOverride : Feature("Send Override") {
|
||||
}.getOrNull()
|
||||
|
||||
if (policyEnum != null) {
|
||||
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,17 +645,228 @@ class SendOverride : Feature("Send Override") {
|
||||
return true
|
||||
}
|
||||
|
||||
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
|
||||
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 mediaBuffers = mutableListOf<ByteArray>()
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
|
||||
fun buildPartMessageContent(partIndex: Int): MessageContent {
|
||||
val partContent = createMessageContentFromOriginal()
|
||||
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
|
||||
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
|
||||
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
partContent.content = ProtoEditor(partContent.content!!).apply {
|
||||
edit(3) {
|
||||
remove(3)
|
||||
addBuffer(3, mediaBuffers[partIndex])
|
||||
}
|
||||
}.toByteArray()
|
||||
if (partIndex < refs.size) {
|
||||
partContent.localMediaReferences = arrayListOf(refs[partIndex])
|
||||
}
|
||||
metadata?.let {
|
||||
if (contentRefs != null && partIndex < contentRefs.size) {
|
||||
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
|
||||
}
|
||||
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
|
||||
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
|
||||
}
|
||||
}
|
||||
return partContent
|
||||
}
|
||||
|
||||
fun sendPart(partIndex: Int) {
|
||||
postSavePolicy = null
|
||||
val partContent = buildPartMessageContent(partIndex)
|
||||
val partReader = ProtoReader(partContent.content ?: return)
|
||||
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
|
||||
|
||||
val callback = if (partIndex == mediaCount - 1) {
|
||||
completionCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendPart(partIndex + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
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" }
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
sendEvent.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedOverrideType != null) {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -649,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,
|
||||
@@ -713,6 +1022,21 @@ class SendOverride : Feature("Send Override") {
|
||||
fun toggleSaveable() {
|
||||
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
disableSplitForCurrentSend = !disableSplitForCurrentSend
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = disableSplitForCurrentSend,
|
||||
onCheckedChange = {
|
||||
disableSplitForCurrentSend = it
|
||||
}
|
||||
)
|
||||
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
toggleSaveable()
|
||||
@@ -749,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,
|
||||
@@ -913,8 +1273,46 @@ 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()) {
|
||||
MediaFilePicker.setQueuedOverrideType(null)
|
||||
}
|
||||
return@Button
|
||||
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
event.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
|
||||
if (delayMs != null && delayMs > 0) {
|
||||
val taskHash = java.util.UUID.randomUUID().toString()
|
||||
@@ -960,8 +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)) {
|
||||
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,
|
||||
@@ -1008,8 +1409,24 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -137,4 +137,4 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap
|
||||
|
||||
private val fetchJobTab = randomTag()
|
||||
private val previewHorizontalAdjustmentDp = 16
|
||||
private val previewVerticalAdjustmentDp = 10
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.userInterface.snapPreview.get()) return
|
||||
@@ -47,9 +49,11 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run {
|
||||
Triple(dpToPx(60), dpToPx(10), dpToPx(15))
|
||||
}
|
||||
val chatMediaCardHeight = context.userInterface.dpToPx(60)
|
||||
val chatMediaCardSnapMargin = context.userInterface.dpToPx(10)
|
||||
val chatMediaCardSnapMarginStartSdl = context.userInterface.dpToPx(15)
|
||||
val previewHorizontalAdjustment = context.userInterface.dpToPx(previewHorizontalAdjustmentDp)
|
||||
val previewVerticalAdjustment = context.userInterface.dpToPx(previewVerticalAdjustmentDp)
|
||||
|
||||
fun decodeMedia(file: File) = runCatching {
|
||||
bitmapCache.getOrPut(file.absolutePath) {
|
||||
@@ -91,8 +95,8 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
val bitmap = bitmapCache[mediaFilePath] ?: return
|
||||
|
||||
canvas.drawBitmap(bitmap,
|
||||
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
|
||||
(canvas.height - bitmap.height) / 2f,
|
||||
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat() + previewHorizontalAdjustment,
|
||||
(canvas.height - bitmap.height) / 2f + previewVerticalAdjustment,
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -101,4 +105,4 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.5.0
|
||||
APP_VERSION_CODE=292
|
||||
APP_VERSION_NAME=1.5.4
|
||||
APP_VERSION_CODE=300
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
android.disallowKotlinSourceSets=false
|
||||
android.sourceset.disallowProvider=false
|
||||
ksp.incremental=false
|
||||
|
||||
Reference in New Issue
Block a user