v1.3.8
This commit is contained in:
@@ -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
|
||||
- [O‑MVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -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!!)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user