mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 21:27:47 +02:00
feat(experimental): media file picker
This commit is contained in:
@ -5,19 +5,15 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.DeadObjectException
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.os.*
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import me.rhunk.snapenhance.bridge.AccountStorage
|
||||
import me.rhunk.snapenhance.bridge.BridgeInterface
|
||||
import me.rhunk.snapenhance.bridge.ConfigStateListener
|
||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
|
||||
import me.rhunk.snapenhance.bridge.SyncCallback
|
||||
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
|
||||
import me.rhunk.snapenhance.bridge.logger.LoggerInterface
|
||||
import me.rhunk.snapenhance.bridge.logger.TrackerInterface
|
||||
import me.rhunk.snapenhance.bridge.scripting.IScripting
|
||||
import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge
|
||||
@ -156,12 +152,22 @@ class BridgeClient(
|
||||
}
|
||||
}
|
||||
|
||||
fun getApplicationApkPath(): String = safeServiceCall { service.getApplicationApkPath() }
|
||||
fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath }
|
||||
|
||||
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall {
|
||||
service.enqueueDownload(intent, callback)
|
||||
}
|
||||
|
||||
fun convertMedia(
|
||||
input: ParcelFileDescriptor,
|
||||
inputExtension: String,
|
||||
outputExtension: String,
|
||||
audioCodec: String?,
|
||||
videoCodec: String?
|
||||
): ParcelFileDescriptor? = safeServiceCall {
|
||||
service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
|
||||
}
|
||||
|
||||
fun sync(callback: SyncCallback) {
|
||||
if (!context.database.hasMain()) return
|
||||
safeServiceCall {
|
||||
|
@ -231,7 +231,7 @@ class EventDispatcher(
|
||||
val instance = param.thisObject<Activity>()
|
||||
val requestCode = param.arg<Int>(0)
|
||||
val resultCode = param.arg<Int>(1)
|
||||
val intent = param.arg<Intent>(2)
|
||||
val intent = param.argNullable<Intent>(2) ?: return@hook
|
||||
|
||||
context.event.post(
|
||||
ActivityResultEvent(
|
||||
|
@ -126,6 +126,7 @@ class FeatureManager(
|
||||
RemoveGroupsLockedStatus(),
|
||||
BypassMessageActionRestrictions(),
|
||||
BetterLocation(),
|
||||
MediaFilePicker(),
|
||||
)
|
||||
|
||||
initializeFeatures()
|
||||
|
@ -119,7 +119,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
context.log.verbose("onSuccess: outputFile=$outputFile")
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Outlined.CheckCircle,
|
||||
text = translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/")),
|
||||
text = translations.format("saved_toast", "path" to outputFile.split("/").takeLast(2).joinToString("/")).also {
|
||||
if (context.isMainActivityPaused) {
|
||||
context.shortToast(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -130,18 +134,22 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
icon = Icons.Outlined.Info,
|
||||
text = message,
|
||||
)
|
||||
// context.shortToast(message)
|
||||
if (context.isMainActivityPaused) {
|
||||
context.shortToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(message: String, throwable: String?) {
|
||||
if (!downloadLogging.contains("failure")) return
|
||||
context.log.verbose("onFailure: message=$message, throwable=$throwable")
|
||||
if (context.isMainActivityPaused) {
|
||||
context.shortToast(message)
|
||||
}
|
||||
throwable?.let {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Outlined.Error,
|
||||
text = message + it.takeIf { it.isNotEmpty() }.orEmpty(),
|
||||
)
|
||||
// context.longToast((message + it.takeIf { it.isNotEmpty() }.orEmpty()))
|
||||
return
|
||||
}
|
||||
|
||||
@ -149,7 +157,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
icon = Icons.Outlined.Warning,
|
||||
text = message,
|
||||
)
|
||||
// context.shortToast(message)
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -596,7 +603,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
|
||||
if (!isPreview) {
|
||||
if (decodedAttachments.size == 1 ||
|
||||
context.mainActivity == null // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
|
||||
context.isMainActivityPaused // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
|
||||
) {
|
||||
downloadMessageAttachments(friendInfo, message, authorName,
|
||||
listOf(decodedAttachments.first()),
|
||||
|
@ -0,0 +1,225 @@
|
||||
package me.rhunk.snapenhance.core.features.impl.experiments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircleOutline
|
||||
import androidx.compose.material.icons.filled.Crop
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import kotlinx.coroutines.launch
|
||||
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
|
||||
import me.rhunk.snapenhance.common.util.ktx.getTypeArguments
|
||||
import me.rhunk.snapenhance.core.event.events.impl.ActivityResultEvent
|
||||
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
|
||||
import me.rhunk.snapenhance.core.features.Feature
|
||||
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
||||
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.core.util.dataBuilder
|
||||
import me.rhunk.snapenhance.core.util.hook.HookStage
|
||||
import me.rhunk.snapenhance.core.util.hook.hook
|
||||
import me.rhunk.snapenhance.core.util.ktx.getId
|
||||
import java.io.InputStream
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.random.Random
|
||||
|
||||
class MediaFilePicker : Feature("Media File Picker", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
|
||||
var lastMediaDuration: Long? = null
|
||||
private set
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
override fun onActivityCreate() {
|
||||
if (!context.config.experimental.mediaFilePicker.get()) return
|
||||
|
||||
lateinit var chatMediaDrawerActionHandler: Any
|
||||
lateinit var sendItemsMethod: Method
|
||||
|
||||
findClass("com.snap.composer.memories.ChatMediaDrawer").genericSuperclass?.getTypeArguments()?.getOrNull(1)?.apply {
|
||||
methods.first {
|
||||
it.parameterTypes.size == 1 && it.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler")
|
||||
}.also { method ->
|
||||
sendItemsMethod = method.parameterTypes[0].methods.first { it.name == "sendItems" }
|
||||
}.hook(HookStage.AFTER) {
|
||||
chatMediaDrawerActionHandler = it.arg(0)
|
||||
}
|
||||
}
|
||||
|
||||
var requestCode: Int? = null
|
||||
var firstVideoId: Long? = null
|
||||
var mediaInputStream: InputStream? = null
|
||||
|
||||
ContentResolver::class.java.apply {
|
||||
hook("query", HookStage.AFTER) { param ->
|
||||
val uri = param.arg<Uri>(0)
|
||||
if (!uri.toString().endsWith(firstVideoId.toString())) return@hook
|
||||
|
||||
param.setResult(object: CursorWrapper(param.getResult() as Cursor) {
|
||||
override fun getLong(columnIndex: Int): Long {
|
||||
if (getColumnName(columnIndex) == "duration") {
|
||||
return lastMediaDuration ?: -1
|
||||
}
|
||||
return super.getLong(columnIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
hook("openInputStream", HookStage.BEFORE) { param ->
|
||||
val uri = param.arg<Uri>(0)
|
||||
if (uri.toString().endsWith(firstVideoId.toString())) {
|
||||
param.setResult(mediaInputStream)
|
||||
mediaInputStream = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(ActivityResultEvent::class) { event ->
|
||||
if (event.requestCode != requestCode || event.resultCode != Activity.RESULT_OK) return@subscribe
|
||||
requestCode = null
|
||||
|
||||
firstVideoId = context.androidContext.contentResolver.query(
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.Video.Media._ID),
|
||||
null,
|
||||
null,
|
||||
"${MediaStore.Video.Media.DATE_TAKEN} DESC"
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getLongOrNull("_id")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (firstVideoId == null) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.Upload,
|
||||
"Must have a video in gallery to upload."
|
||||
)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
fun sendMedia() {
|
||||
sendItemsMethod.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(
|
||||
sendItemsMethod.genericParameterTypes[1].getTypeArguments().first().dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", 0.0)
|
||||
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)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fun startConversation(audioOnly: Boolean) {
|
||||
context.coroutineScope.launch {
|
||||
lastMediaDuration = MediaPlayer().run {
|
||||
setDataSource(context.androidContext, event.intent.data!!)
|
||||
prepare()
|
||||
duration.toLong().also {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000)
|
||||
val pfd = context.bridgeClient.convertMedia(
|
||||
context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!,
|
||||
"m4a",
|
||||
"m4a",
|
||||
"aac",
|
||||
if (!audioOnly) "libx264" else null
|
||||
)
|
||||
|
||||
if (pfd == null) {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to convert media.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
|
||||
|
||||
runCatching {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
context.log.verbose("Media duration: $lastMediaDuration")
|
||||
sendMedia()
|
||||
}.onFailure {
|
||||
mediaInputStream = null
|
||||
context.log.error(it)
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
|
||||
|
||||
if (isAudio || !context.config.messaging.galleryMediaSendOverride.get()) {
|
||||
startConversation(isAudio)
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
|
||||
.setTitle("Convert video file")
|
||||
.setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which ->
|
||||
startConversation(which == 1)
|
||||
}
|
||||
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }.show()
|
||||
}
|
||||
|
||||
val buttonTag = Random.nextInt(0, 65535)
|
||||
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.parent.id != context.resources.getId("chat_drawer_container") || !event.view::class.java.name.endsWith("ChatMediaDrawer")) return@subscribe
|
||||
|
||||
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
event.parent.addView(
|
||||
Button(event.parent.context).apply {
|
||||
text = "Upload"
|
||||
tag = buttonTag
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setOnClickListener {
|
||||
requestCode = Random.nextInt(0, 65535)
|
||||
this@MediaFilePicker.context.mainActivity!!.startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "video/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*"))
|
||||
},
|
||||
requestCode!!
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
event.parent.findViewWithTag<View>(buttonTag)?.let {
|
||||
event.parent.removeView(it)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@ import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.rhunk.snapenhance.core.features.Feature
|
||||
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
||||
import me.rhunk.snapenhance.core.features.impl.experiments.MediaFilePicker
|
||||
import me.rhunk.snapenhance.core.messaging.MessageSender
|
||||
import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.nativelib.NativeLib
|
||||
@ -134,10 +135,8 @@ class SendOverride : Feature("Send Override", loadParams = FeatureLoadParams.INI
|
||||
|
||||
"NOTE" -> {
|
||||
localMessageContent.contentType = ContentType.NOTE
|
||||
val mediaDuration =
|
||||
messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: 0
|
||||
localMessageContent.content =
|
||||
MessageSender.audioNoteProto(mediaDuration)
|
||||
MessageSender.audioNoteProto(messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import java.lang.reflect.Member
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
object Hooker {
|
||||
inline fun newMethodHook(
|
||||
@ -166,7 +167,7 @@ fun Member.hook(
|
||||
): XC_MethodHook.Unhook = Hooker.hook(this, stage, filter, consumer)
|
||||
|
||||
fun Array<Method>.hookAll(stage: HookStage, param: (HookAdapter) -> Unit) {
|
||||
filter { it.declaringClass != Object::class.java }.forEach {
|
||||
filter { it.declaringClass != Object::class.java && !Modifier.isAbstract(it.modifiers) }.forEach {
|
||||
it.hook(stage, param)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user