commit 44eccb66d1c68becd89100c6e146c0f7e785a647 parent c2816766a8c12c025439e985bb1286db365ab1e2 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Sun, 24 Mar 2024 18:43:30 +0100 feat(experimental): media file picker Diffstat:
14 files changed, 350 insertions(+), 22 deletions(-)
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/BridgeService.kt @@ -3,6 +3,8 @@ package me.rhunk.snapenhance.bridge import android.app.Service import android.content.Intent import android.os.IBinder +import android.os.ParcelFileDescriptor +import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.SharedContextHolder import me.rhunk.snapenhance.bridge.snapclient.MessagingBridge @@ -15,6 +17,11 @@ import me.rhunk.snapenhance.common.data.SocialScope import me.rhunk.snapenhance.common.logger.LogLevel import me.rhunk.snapenhance.common.util.toParcelable import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.download.FFMpegProcessor +import me.rhunk.snapenhance.task.Task +import me.rhunk.snapenhance.task.TaskType +import java.io.File +import java.util.UUID import kotlin.system.measureTimeMillis class BridgeService : Service() { @@ -131,6 +138,61 @@ class BridgeService : Service() { ).onReceive(intent) } + override fun convertMedia( + input: ParcelFileDescriptor?, + inputExtension: String, + outputExtension: String, + audioCodec: String?, + videoCodec: String? + ): ParcelFileDescriptor? { + return runBlocking { + val taskId = UUID.randomUUID().toString() + val inputFile = File.createTempFile(taskId, ".$inputExtension", remoteSideContext.androidContext.cacheDir) + + runCatching { + ParcelFileDescriptor.AutoCloseInputStream(input).use { inputStream -> + inputFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + }.onFailure { + remoteSideContext.log.error("Failed to copy input file", it) + inputFile.delete() + return@runBlocking null + } + val cachedFile = File.createTempFile(taskId, ".$outputExtension", remoteSideContext.androidContext.cacheDir) + + val pendingTask = remoteSideContext.taskManager.createPendingTask( + Task( + type = TaskType.DOWNLOAD, + title = "Media conversion", + author = null, + hash = taskId + ) + ) + runCatching { + FFMpegProcessor.newFFMpegProcessor(remoteSideContext, pendingTask).execute( + FFMpegProcessor.Request( + action = FFMpegProcessor.Action.CONVERSION, + inputs = listOf(inputFile.absolutePath), + output = cachedFile, + videoCodec = videoCodec, + audioCodec = audioCodec + ) + ) + pendingTask.success() + return@runBlocking ParcelFileDescriptor.open(cachedFile, ParcelFileDescriptor.MODE_READ_ONLY) + }.onFailure { + pendingTask.fail(it.message ?: "Failed to convert video") + remoteSideContext.log.error("Failed to convert video", it) + } + + inputFile.delete() + cachedFile.delete() + null + } + } + override fun getRules(uuid: String): List<String> { return remoteSideContext.modDatabase.getRules(uuid).map { it.key } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -253,7 +253,7 @@ class DownloadProcessor ( remoteSideContext.config.root.downloader.forceVoiceNoteFormat.getNullable()?.let { format -> val outputFile = File.createTempFile("voice_note", ".$format") newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( - action = FFMpegProcessor.Action.AUDIO_CONVERSION, + action = FFMpegProcessor.Action.CONVERSION, inputs = listOf(media.file.absolutePath), output = outputFile )) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -63,7 +63,7 @@ class FFMpegProcessor( enum class Action { DOWNLOAD_DASH, MERGE_OVERLAY, - AUDIO_CONVERSION, + CONVERSION, MERGE_MEDIA, DOWNLOAD_AUDIO_STREAM, } @@ -76,6 +76,9 @@ class FFMpegProcessor( val startTime: Long? = null, //only for DOWNLOAD_DASH val duration: Long? = null, //only for DOWNLOAD_DASH val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM + + var videoCodec: String? = null, + var audioCodec: String? = null, ) @@ -142,12 +145,19 @@ class FFMpegProcessor( inputArguments += "-i" to args.overlay!!.absolutePath outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" } - Action.AUDIO_CONVERSION -> { + Action.CONVERSION -> { if (ffmpegOptions.customAudioCodec.isEmpty()) { outputArguments -= "-c:a" } - if (ffmpegOptions.customVideoCodec.isEmpty()) { - outputArguments -= "-c:v" + outputArguments -= "-c:v" + args.videoCodec?.let { + outputArguments += "-c:v" to it + } ?: run { + outputArguments += "-vn" + } + args.audioCodec?.let { + outputArguments -= "-c:a" + outputArguments += "-c:a" to it } } Action.MERGE_MEDIA -> { diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/BridgeInterface.aidl @@ -41,6 +41,11 @@ interface BridgeInterface { oneway void enqueueDownload(in Intent intent, DownloadCallback callback); /** + * File conversation + */ + @nullable ParcelFileDescriptor convertMedia(in ParcelFileDescriptor input, String inputExtension, String outputExtension, @nullable String audioCodec, @nullable String videoCodec); + + /** * Get rules for a given user or conversation * @return list of rules (MessagingRuleType) */ diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -734,6 +734,10 @@ "name": "New Chat Action Menu", "description": "Use the new chat action menu drawer" }, + "media_file_picker": { + "name": "Media File Picker", + "description": "Allows you to pick any video/audio file from the gallery" + }, "story_logger": { "name": "Story Logger", "description": "Provides a history of friends stories" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -28,6 +28,7 @@ class Experimental : ConfigContainer() { val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val newChatActionMenu = boolean("new_chat_action_menu") { requireRestart() } + val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val callRecorder = boolean("call_recorder") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/JavaExt.kt @@ -1,6 +1,8 @@ package me.rhunk.snapenhance.common.util.ktx import java.lang.reflect.Field +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type fun String.longHashCode(): Long { var h = 1125899906842597L @@ -34,3 +36,7 @@ inline fun Class<*>.findFieldsToString(instance: Any? = null, once: Boolean = fa } } } + +fun Type.getTypeArguments(): List<Class<*>> { + return (this as? ParameterizedType)?.actualTypeArguments?.mapNotNull { it as? Class<*> } ?: emptyList() +} diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/bridge/BridgeClient.kt @@ -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 { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/event/EventDispatcher.kt @@ -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( diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/FeatureManager.kt @@ -126,6 +126,7 @@ class FeatureManager( RemoveGroupsLockedStatus(), BypassMessageActionRestrictions(), BetterLocation(), + MediaFilePicker(), ) initializeFeatures() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -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()), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/experiments/MediaFilePicker.kt @@ -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) + } + } + }) + } + } +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/SendOverride.kt @@ -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) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/util/hook/Hooker.kt @@ -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) } }