This commit is contained in:
particle-box
2026-03-07 04:33:21 +05:30
parent 89748cd261
commit 28982aa655
16 changed files with 576 additions and 121 deletions

View File

@@ -324,7 +324,7 @@ PurrfectSnap is built with exceptional open source tools. We do not collect any
**Core Dependencies**
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
- [libxposed](https://github.com/libxposed/api) — Framework integration
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
- [OMVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
object UpdateDownloader {
private const val TAG = "UpdateDownloader"
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private val fallbackHttpClient by lazy { OkHttpClient() }
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
@@ -84,6 +87,132 @@ object UpdateDownloader {
return downloadedFile
}
private fun scheduleReset(scope: CoroutineScope) {
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
downloadProgress.value = 0f
}
}
private fun installDownloadedFile(
remoteContext: RemoteSideContext,
downloadedFile: File,
scope: CoroutineScope
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.COMPLETED
runCatching {
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
scheduleReset(scope)
}
private fun failDownload(
remoteContext: RemoteSideContext,
scope: CoroutineScope,
errorMessage: String,
throwable: Throwable? = null
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to errorMessage),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
scheduleReset(scope)
}
private fun startHttpFallbackDownload(
remoteContext: RemoteSideContext,
downloadUrl: String,
filePath: String,
scope: CoroutineScope
) {
val partialFile = File("$filePath.part")
val outputFile = File(filePath)
scope.launch(Dispatchers.IO) {
runCatching {
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
partialFile.parentFile?.mkdirs()
if (partialFile.exists()) partialFile.delete()
if (outputFile.exists()) outputFile.delete()
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "PurrfectSnap-Updater")
.build()
fallbackHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("HTTP_${response.code}")
}
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
val contentLength = body.contentLength()
var downloadedBytes = 0L
body.byteStream().use { input ->
partialFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
}
}
}
}
}
if (!partialFile.renameTo(outputFile)) {
partialFile.copyTo(outputFile, overwrite = true)
partialFile.delete()
}
installDownloadedFile(remoteContext, outputFile, scope)
}.onFailure {
runCatching { partialFile.delete() }
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
}
}
}
fun downloadAndInstall(
remoteContext: RemoteSideContext,
downloadUrl: String,
@@ -100,6 +229,7 @@ object UpdateDownloader {
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
var fallbackAttempted = false
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
@@ -116,60 +246,21 @@ object UpdateDownloader {
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
runCatching {
val downloadedFile = File(download.file)
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
installDownloadedFile(remoteContext, File(download.file), scope)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to error.toString()),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
?: remoteContext.log.error("Update download failed: $error", TAG)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
fallbackAttempted = true
downloadState.value = DownloadState.DOWNLOADING
downloadProgress.value = 0f
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
return
}
failDownload(remoteContext, scope, error.toString(), throwable)
}
}
fetch.addListener(listener!!)

View File

@@ -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.3.6").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("276").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("278").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.

View File

@@ -1,3 +1,11 @@
## v1.3.8
- Fix: Download profile picture button shape
- New: Auto Patcher(Jingmatrix Lspatch) updated to v0.8
- Fix: Video playback rate slider mobility
- New: Redesign Convert Message dialog
- Fix: Media File Picker issues
- Fix: REQUEST_NOT_SUCCESSFUL issues while updating PurrfectSnap for some devices
## v1.3.6
- Fix: Crash issues for some devices
- Fix: Scroll state

View File

@@ -3247,6 +3247,9 @@
"select_date_first": "Please select a date",
"invalid_time": "Please select a future time"
},
"convert_message_dialog": {
"subtitle": "Choose how this message should be reshaped locally."
},
"spotlight_creator_info": {
"title": "Creator Info",
"close": "Close",

View File

@@ -1,9 +1,15 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.annotation.SuppressLint
import android.app.Activity
import android.content.res.ColorStateList
import android.graphics.drawable.GradientDrawable
import android.util.TypedValue
import android.widget.Button as AndroidButton
import android.widget.RelativeLayout
import android.widget.ImageView
import android.widget.ImageButton
import android.widget.FrameLayout
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -42,8 +48,13 @@ import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.ktx.isDarkTheme
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
private companion object {
const val DOWNLOAD_BUTTON_TAG = "profile_picture_download_button"
}
@SuppressLint("SetTextI18n")
override fun init() {
if (!context.config.downloader.downloadProfilePictures.get()) return
@@ -62,31 +73,38 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name !in profileViewClasses) return@subscribe
val activity = context.mainActivity ?: return@subscribe
val rootContent = activity.findViewById<FrameLayout>(android.R.id.content) ?: return@subscribe
val buttonText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
if ((0 until event.parent.childCount).any {
val child = event.parent.getChildAt(it)
child is AndroidButton && child.contentDescription == buttonText
}) return@subscribe
rootContent.findViewWithTag<View>(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) }
event.parent.addView(AndroidButton(event.parent.context).apply {
text = ""
contentDescription = buttonText
val button = ImageButton(activity).apply {
val density = resources.displayMetrics.density
val buttonHeight = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48f, resources.displayMetrics).toInt()
minWidth = 0
minimumWidth = 0
minHeight = buttonHeight
minimumHeight = buttonHeight
setPadding(
(6 * density).toInt(),
0,
0,
0
val buttonSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40f, resources.displayMetrics).toInt()
val iconPadding = (8 * density).toInt()
val darkTheme = context.isDarkTheme()
tag = DOWNLOAD_BUTTON_TAG
contentDescription = buttonText
scaleType = ImageView.ScaleType.CENTER
setImageResource(android.R.drawable.stat_sys_download)
imageTintList = ColorStateList.valueOf(
if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#151A1A")
)
setCompoundDrawablesWithIntrinsicBounds(android.R.drawable.stat_sys_download, 0, 0, 0)
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, buttonHeight).apply {
setMargins((8 * density).toInt(), 200, 0, 0)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(
if (darkTheme) android.graphics.Color.parseColor("#1D1D1D")
else android.graphics.Color.WHITE
)
}
setPadding(iconPadding, iconPadding, iconPadding, iconPadding)
minimumWidth = 0
minimumHeight = 0
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
stateListAnimator = null
}
layoutParams = FrameLayout.LayoutParams(buttonSize, buttonSize)
setOnClickListener {
val choices = buildList {
backgroundUrl?.let {
@@ -109,7 +127,7 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
}
createComposeAlertDialog(
createComposeAlertDialog(
this@ProfilePictureDownloader.context.mainActivity!!,
content = { alertDialog ->
ProfilePictureDialog(
@@ -138,7 +156,47 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
).show()
}
}
val leftOffsetPx = (8 * activity.resources.displayMetrics.density).toInt()
val topOffsetPx = 236
val anchorView = event.view
val positionUpdater = ViewTreeObserver.OnPreDrawListener {
updateOverlayButtonPosition(
activity = activity,
anchorView = anchorView,
button = button,
leftOffsetPx = leftOffsetPx,
topOffsetPx = topOffsetPx
)
true
}
anchorView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) {
if (rootContent.viewTreeObserver.isAlive) {
rootContent.viewTreeObserver.removeOnPreDrawListener(positionUpdater)
}
rootContent.findViewWithTag<View>(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) }
v.removeOnAttachStateChangeListener(this)
}
})
rootContent.addView(button)
rootContent.viewTreeObserver.addOnPreDrawListener(positionUpdater)
rootContent.post {
updateOverlayButtonPosition(
activity = activity,
anchorView = anchorView,
button = button,
leftOffsetPx = leftOffsetPx,
topOffsetPx = topOffsetPx
)
button.bringToFront()
}
}
@@ -378,4 +436,24 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
AVATAR,
BACKGROUND
}
private fun updateOverlayButtonPosition(
activity: Activity,
anchorView: View,
button: View,
leftOffsetPx: Int,
topOffsetPx: Int
) {
if (!anchorView.isAttachedToWindow || !button.isAttachedToWindow) return
val rootContent = activity.findViewById<FrameLayout>(android.R.id.content) ?: return
val rootLocation = IntArray(2)
val anchorLocation = IntArray(2)
rootContent.getLocationOnScreen(rootLocation)
anchorView.getLocationOnScreen(anchorLocation)
button.x = (anchorLocation[0] - rootLocation[0] + leftOffsetPx).toFloat()
button.y = (anchorLocation[1] - rootLocation[1] + topOffsetPx).toFloat()
}
}

View File

@@ -1,12 +1,45 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cached
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
@@ -26,8 +59,8 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
fun convertMessageInterface(messageInstance: Message) {
val actions = mutableMapOf<String, (Message) -> Unit>()
actions[context.translation["button.restore_original"]] = actions@{ message ->
val actions = mutableListOf<Pair<String, (Message) -> Unit>>()
actions += context.translation["button.restore_original"] to actions@{ message ->
val descriptor = message.messageDescriptor ?: return@actions
messageCache.remove(descriptor.messageId!!)
context.feature(Messaging::class).conversationManager?.fetchMessage(
@@ -41,7 +74,7 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
val contentType = messageInstance.messageContent?.contentType
if (contentType == ContentType.SNAP) {
actions[context.translation["button.convert_external_media"]] = convert@{ message ->
actions += context.translation["button.convert_external_media"] to convert@{ message ->
val snapMessageContent = ProtoReader(message.messageContent!!.content!!).followPath(11)
?.getBuffer() ?: return@convert
message.messageContent!!.content = ProtoWriter().apply {
@@ -53,13 +86,26 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply {
setItems(actions.keys.toTypedArray()) { _, which ->
actions.values.elementAt(which).invoke(messageInstance)
}
setPositiveButton(this@ConvertMessageLocally.context.translation["button.cancel"]) { dialog, _ ->
dialog.dismiss()
}
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
ConvertMessageDialog(
title = context.translation["convert_message"],
subtitle = context.translation["convert_message_dialog.subtitle"],
closeLabel = context.translation["button.cancel"],
actions = actions.map { (label, _) ->
ConvertMessageAction(
label = label,
icon = when (label) {
context.translation["button.restore_original"] -> Icons.Default.Restore
else -> Icons.Default.Cached
}
)
},
onSelect = { index ->
actions.getOrNull(index)?.second?.invoke(messageInstance)
alertDialog.dismiss()
},
onDismiss = { alertDialog.dismiss() }
)
}.show()
}
@@ -72,4 +118,162 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
}
}
}
@Composable
private fun ConvertMessageDialog(
title: String,
subtitle: String,
closeLabel: String,
actions: List<ConvertMessageAction>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit
) {
val shape = remember { RoundedCornerShape(24.dp) }
val overlayBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.95f),
Color(0xFF1A143A).copy(alpha = 0.92f)
)
)
}
val accentBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.42f),
Color(0xFF5FD8FF).copy(alpha = 0.34f)
)
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = shape,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2452).copy(alpha = 0.95f))
) {
Box(
modifier = Modifier
.background(overlayBrush, shape)
.padding(20.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(accentBrush, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.EditNote,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(30.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Center
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
actions.forEachIndexed { index, action ->
ConvertMessageOptionCard(
label = action.label,
icon = action.icon,
onClick = { onSelect(index) }
)
}
}
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8C7BFF).copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(
text = closeLabel,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
@Composable
private fun ConvertMessageOptionCard(
label: String,
icon: ImageVector,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.18f),
Color(0xFF5FD8FF).copy(alpha = 0.1f)
)
),
RoundedCornerShape(18.dp)
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(42.dp)
.background(Color.White.copy(alpha = 0.1f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
private data class ConvertMessageAction(
val label: String,
val icon: ImageVector
)
}

View File

@@ -6,10 +6,11 @@ import android.content.ContentResolver
import android.content.Intent
import android.database.Cursor
import android.database.CursorWrapper
import android.media.MediaPlayer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -39,12 +40,12 @@ import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getTypeArguments
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
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.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -57,6 +58,35 @@ class MediaFilePicker : Feature("Media File Picker") {
var lastMediaDuration: Long? = null
private set
private fun extractMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return runCatching {
retriever.setDataSource(context.androidContext, uri)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
}.getOrNull().also {
runCatching { retriever.release() }
}
}
private fun resolveInputExtension(uri: Uri, mimeType: String?): String {
val extensionFromMime = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }?.lowercase()
val extensionFromUri = MimeTypeMap.getFileExtensionFromUrl(uri.toString()).takeIf { !it.isNullOrBlank() }?.lowercase()
return when (extensionFromMime ?: extensionFromUri ?: mimeType) {
"video/mp4", "audio/mp4", "application/mp4", "mp4", "m4v" -> "mp4"
"video/quicktime", "mov", "qt" -> "mov"
"video/webm", "webm" -> "webm"
"video/x-matroska", "video/mkv", "mkv" -> "mkv"
"video/avi", "video/x-msvideo", "avi" -> "avi"
"audio/mpeg", "audio/mp3", "mp3" -> "mp3"
"audio/aac", "aac" -> "aac"
"audio/ogg", "audio/opus", "opus", "ogg" -> "opus"
"audio/wav", "audio/x-wav", "wav" -> "wav"
"audio/mp4a-latm", "audio/x-m4a", "m4a" -> "m4a"
else -> FileType.fromString(extensionFromMime ?: extensionFromUri).fileExtension ?: "mp4"
}
}
@SuppressLint("Recycle")
override fun init() {
if (!context.config.experimental.mediaFilePicker.get()) return
@@ -154,7 +184,7 @@ class MediaFilePicker : Feature("Media File Picker") {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", 0.0)
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
@@ -175,19 +205,27 @@ class MediaFilePicker : Feature("Media File Picker") {
fun startConversion(audioOnly: Boolean) {
context.coroutineScope.launch {
lastMediaDuration = MediaPlayer().run {
setDataSource(context.androidContext, event.intent.data!!)
prepare()
duration.toLong().also {
release()
}
val pickedUri = event.intent?.data ?: run {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "No media was selected.")
return@launch
}
val mimeType = context.androidContext.contentResolver.getType(pickedUri)
val inputExtension = resolveInputExtension(pickedUri, mimeType)
val outputExtension = if (audioOnly || mimeType?.startsWith("audio/") == true) "m4a" else "mp4"
lastMediaDuration = extractMediaDuration(pickedUri)
context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000)
val pickedFileDescriptor = context.androidContext.contentResolver.openFileDescriptor(pickedUri, "r")
if (pickedFileDescriptor == null) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to open selected media.")
return@launch
}
val pfd = context.bridgeClient.convertMedia(
context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!,
"m4a",
"m4a",
pickedFileDescriptor,
inputExtension,
outputExtension,
"aac",
if (!audioOnly) "libx264" else null
)
@@ -210,14 +248,15 @@ class MediaFilePicker : Feature("Media File Picker") {
}
}
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
val pickedUri = event.intent?.data ?: return@subscribe
val isAudio = context.androidContext.contentResolver.getType(pickedUri)?.startsWith("audio/") == true
if (isAudio || context.config.messaging.galleryMediaSendOverride.mode.getNullable() == null) {
startConversion(isAudio)
return@subscribe
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
android.app.AlertDialog.Builder(context.mainActivity!!)
.setTitle("Convert video file")
.setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which ->
startConversion(which == 1)
@@ -313,4 +352,5 @@ class MediaFilePicker : Feature("Media File Picker") {
}
}
}
}

View File

@@ -1,12 +1,15 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.TextView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -25,8 +28,6 @@ import androidx.compose.material.icons.filled.SlowMotionVideo
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -35,9 +36,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.use
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
@@ -225,28 +228,56 @@ class OperaContextActionMenu : AbstractMenu() {
fontWeight = FontWeight.ExtraBold
)
Text(
text = "x" + value.toString().take(4),
text = "x" + String.format("%.2f", value),
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Start
)
}
}
Slider(
value = value,
onValueChange = {
value = it
operaViewerParamsOverride.currentPlaybackRate = it
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { androidContext ->
SeekBar(androidContext).apply {
max = 390
progress = ((value - 0.1f) * 100).toInt().coerceIn(0, max)
thumbTintList = ColorStateList.valueOf(Color.White.toArgb())
progressTintList = ColorStateList.valueOf(glowSecondary.toArgb())
progressBackgroundTintList = ColorStateList.valueOf(Color.White.copy(alpha = 0.16f).toArgb())
splitTrack = false
setOnTouchListener { seekBar, motionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> seekBar.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> seekBar.parent?.requestDisallowInterceptTouchEvent(false)
}
false
}
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
val playbackRate = (0.1f + (progress / 100f)).coerceIn(0.1f, 4.0f)
value = playbackRate
operaViewerParamsOverride.currentPlaybackRate = playbackRate
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
seekBar?.parent?.requestDisallowInterceptTouchEvent(true)
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
seekBar?.parent?.requestDisallowInterceptTouchEvent(false)
}
})
}
},
valueRange = 0.1F..4.0F,
steps = 0,
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = glowSecondary,
inactiveTrackColor = Color.White.copy(alpha = 0.16f),
activeTickColor = glowPrimary,
inactiveTickColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth()
update = { seekBar ->
val targetProgress = ((value - 0.1f) * 100).toInt().coerceIn(0, seekBar.max)
if (seekBar.progress != targetProgress) {
seekBar.progress = targetProgress
}
}
)
Row(modifier = Modifier.fillMaxWidth()) {
Text(

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.3.6
APP_VERSION_CODE=276
APP_VERSION_NAME=1.3.8
APP_VERSION_CODE=278
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c