commit 2ee64c40adc0980199e1e7c234282dbcc9eab88e
parent fca2f8a53dfb78551b6a713f7eb2be5f5b9e4980
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 17 Jun 2023 11:40:36 +0200

feat: download manager (#65)

* fix: media scanner

* refactor!: media downloader feature

* fix(notifications): sender username

* fix: ffmpeg merge filter

* fix: ffmpeg timestamp

* fix(bridge): sendMessage coroutine

* fix(media_downloader): friendinfo icon

* download manager database

* remove all tasks button

* revert: preview group chat

* add: encryption key throwable

* feat: download manager preview

* feat: preview deleted medias

* message logger database schema

* fix: send media override
changed media duration: int -> long

* refactor: download request

* bitmoji selfie update

* refactor: utils
Diffstat:
Mapp/build.gradle | 1+
Mapp/src/main/AndroidManifest.xml | 20++++++++++++++------
Mapp/src/main/kotlin/me/rhunk/snapenhance/Constants.kt | 13++++---------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt | 10+++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt | 36+++++++++++++++---------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt | 28++++++++++++++++++++--------
Mapp/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt | 2+-
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/ClientDownloadManager.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/MediaDownloadReceiver.kt | 330+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt | 22++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadMediaType.kt | 23+++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadStage.kt | 15+++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt | 29+++++++++++++++--------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt | 366++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt | 9+++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt | 6+++---
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt | 2+-
Mapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt | 41+++++++++++++++++++++--------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt | 10----------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt | 99-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt | 148-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt | 93-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt | 94-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt | 373-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt | 82-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt | 243-------------------------------------------------------------------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt | 2+-
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/map/MapActivity.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/AbstractMenu.kt | 10++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/ViewAppearanceHelper.kt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt | 376+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt | 67-------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt | 118-------------------------------------------------------------------------------
Dapp/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt | 41-----------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt | 32++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt | 91+++++++++++++++++++++++++++++++++++++------------------------------------------
Aapp/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt | 16++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt | 2--
Aapp/src/main/res/drawable/action_button_cancel.xml | 5+++++
Aapp/src/main/res/drawable/action_button_success.xml | 9+++++++++
Aapp/src/main/res/drawable/download_manager_item_background.xml | 6++++++
Aapp/src/main/res/font/avenir_next_medium.ttf | 0
Aapp/src/main/res/layout/download_manager_activity.xml | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/res/layout/download_manager_item.xml | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/res/values/colors.xml | 13+++++++++++++
Aapp/src/main/res/values/dimens.xml | 5+++++
Aapp/src/main/res/values/themes.xml | 7+++++++
61 files changed, 3029 insertions(+), 1694 deletions(-)

diff --git a/app/build.gradle b/app/build.gradle @@ -95,6 +95,7 @@ task getVersion { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21' + implementation 'androidx.recyclerview:recyclerview:1.3.0' compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar') implementation 'com.google.code.gson:gson:2.10.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> + <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" /> <uses-permission android:name="android.permission.INTERNET" /> @@ -28,21 +30,27 @@ <service android:name=".bridge.service.BridgeService" - android:exported="true"> + android:exported="true" + tools:ignore="ExportedService"> </service> + <receiver android:name=".download.MediaDownloadReceiver" android:exported="true" + tools:ignore="ExportedReceiver"> + <intent-filter> + <action android:name="me.rhunk.snapenhance.download.MediaDownloadReceiver.DOWNLOAD_ACTION" /> + </intent-filter> + </receiver> + <activity - android:theme="@android:style/Theme.NoDisplay" - android:name=".bridge.service.MainActivity" - android:exported="true" - android:excludeFromRecents="true"> + android:name=".ui.download.DownloadManagerActivity" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity - android:name=".features.impl.ui.menus.MapActivity" + android:name=".ui.map.MapActivity" android:exported="true" android:excludeFromRecents="true" /> </application> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt @@ -5,18 +5,13 @@ object Constants { const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" const val VIEW_INJECTED_CODE = 0x7FFFFF02 - const val VIEW_DRAWER = 0x7FFFFF03 - val ARROYO_NOTE_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 6, 1, 1) - val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1) - val MESSAGE_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(11, 5, 1, 1) - val MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(3, 3, 5, 1, 1) - val ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 3, 3, 5, 1, 1) - val ARROYO_STRING_CHAT_MESSAGE_PROTO = intArrayOf(4, 4, 2, 1) + val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4) + val ARROYO_STRING_CHAT_MESSAGE_PROTO = ARROYO_MEDIA_CONTAINER_PROTO_PATH + intArrayOf(2, 1) val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3) - const val ARROYO_ENCRYPTION_PROTO_INDEX = 19 - const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4 + const val ENCRYPTION_PROTO_INDEX = 19 + const val ENCRYPTION_PROTO_INDEX_V2 = 4 const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt @@ -40,7 +40,7 @@ class ModContext { val config = ConfigManager(this) val actionManager = ActionManager(this) val database = DatabaseAccess(this) - val downloadServer = DownloadServer(this) + val downloadServer = DownloadServer() val messageSender = MessageSender(this) val classCache get() = SnapEnhance.classCache val resources: Resources get() = androidContext.resources diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/OpenMap.kt @@ -5,7 +5,7 @@ import android.os.Bundle import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.action.AbstractAction import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.impl.ui.menus.MapActivity +import me.rhunk.snapenhance.ui.map.MapActivity class OpenMap: AbstractAction("action.open_map", dependsOnProperty = ConfigProperty.LOCATION_SPOOF) { override fun run() { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/MessageLoggerWrapper.kt @@ -2,6 +2,7 @@ package me.rhunk.snapenhance.bridge import android.content.ContentValues import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import java.io.File class MessageLoggerWrapper( @@ -12,7 +13,14 @@ class MessageLoggerWrapper( fun init() { database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) - database.execSQL("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, conversation_id VARCHAR, message_id BIGINT, message_data BLOB)") + SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf( + "messages" to listOf( + "id INTEGER PRIMARY KEY", + "conversation_id VARCHAR", + "message_id BIGINT", + "message_data BLOB" + ) + )) } fun deleteMessage(conversationId: String, messageId: Long) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/ServiceBridgeClient.kt @@ -13,6 +13,8 @@ import android.os.HandlerThread import android.os.IBinder import android.os.Message import android.os.Messenger +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.bridge.AbstractBridgeClient @@ -86,29 +88,21 @@ class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection { messageType: BridgeMessageType, bridgeMessage: BridgeMessage, resultType: KClass<T>? = null - ): T { - val response = AtomicReference<BridgeMessage>() - val condition = lock.newCondition() - - with(Message.obtain()) { - what = messageType.value - replyTo = Messenger(object : Handler(handlerThread.looper) { - override fun handleMessage(msg: Message) { - response.set(handleResponseMessage(msg)) - lock.withLock { - condition.signal() + ) = runBlocking { + return@runBlocking suspendCancellableCoroutine<T> { continuation -> + with(Message.obtain()) { + what = messageType.value + replyTo = Messenger(object : Handler(handlerThread.looper) { + override fun handleMessage(msg: Message) { + if (continuation.isCompleted) return + continuation.resumeWith(Result.success(handleResponseMessage(msg) as T)) } - } - }) - data = Bundle() - bridgeMessage.write(data) - messenger.send(this) - } - - lock.withLock { - condition.awaitUninterruptibly() + }) + data = Bundle() + bridgeMessage.write(data) + messenger.send(this) + } } - return response.get() as T } override fun createAndReadFile( diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt @@ -1,19 +1,23 @@ package me.rhunk.snapenhance.data +import java.io.File + enum class FileType( val fileExtension: String? = null, + val mimeType: String, val isVideo: Boolean = false, val isImage: Boolean = false, val isAudio: Boolean = false ) { - GIF("gif", false, false, false), - PNG("png", false, true, false), - MP4("mp4", true, false, false), - MP3("mp3", false, false, true), - JPG("jpg", false, true, false), - ZIP("zip", false, false, false), - WEBP("webp", false, true, false), - UNKNOWN("dat", false, false, false); + GIF("gif", "image/gif", false, false, false), + PNG("png", "image/png", false, true, false), + MP4("mp4", "video/mp4", true, false, false), + MP3("mp3", "audio/mp3",false, false, true), + JPG("jpg", "image/jpg",false, true, false), + ZIP("zip", "application/zip", false, false, false), + WEBP("webp", "image/webp", false, true, false), + MPD("mpd", "text/xml", false, false, false), + UNKNOWN("dat", "application/octet-stream", false, false, false); companion object { private val fileSignatures = HashMap<String, FileType>() @@ -40,6 +44,14 @@ enum class FileType( return result.toString() } + fun fromFile(file: File): FileType { + file.inputStream().use { inputStream -> + val buffer = ByteArray(16) + inputStream.read(buffer) + return fromByteArray(buffer) + } + } + fun fromByteArray(array: ByteArray): FileType { val headerBytes = ByteArray(16) System.arraycopy(array, 0, headerBytes, 0, 16) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/MessageSender.kt @@ -31,7 +31,7 @@ class MessageSender( }.toByteArray() } - val audioNoteProto: (Int) -> ByteArray = { duration -> + val audioNoteProto: (Long) -> ByteArray = { duration -> ProtoWriter().apply { write(6, 1) { write(1) { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/ClientDownloadManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/ClientDownloadManager.kt @@ -0,0 +1,82 @@ +package me.rhunk.snapenhance.download + +import android.content.Intent +import android.os.Bundle +import me.rhunk.snapenhance.BuildConfig +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.download.data.DownloadRequest +import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.download.enums.DownloadMediaType + +class ClientDownloadManager ( + private val context: ModContext, + private val outputPath: String, + private val mediaDisplaySource: String?, + private val mediaDisplayType: String?, + private val iconUrl: String? +) { + private fun sendToBroadcastReceiver(bundle: Bundle) { + val intent = Intent() + intent.setClassName(BuildConfig.APPLICATION_ID, MediaDownloadReceiver::class.java.name) + intent.action = MediaDownloadReceiver.DOWNLOAD_ACTION + intent.putExtras(bundle) + context.androidContext.sendBroadcast(intent) + } + + private fun sendToBroadcastReceiver( + request: DownloadRequest, + extras: Bundle.() -> Unit = {} + ) { + sendToBroadcastReceiver(request.toBundle().apply { + putString("outputPath", outputPath) + putString("mediaDisplaySource", mediaDisplaySource) + putString("mediaDisplayType", mediaDisplayType) + putString("iconUrl", iconUrl) + }.apply(extras)) + } + + fun downloadDashMedia(playlistUrl: String, offsetTime: Long, duration: Long) { + sendToBroadcastReceiver( + DownloadRequest( + inputMedias = arrayOf(playlistUrl), + inputTypes = arrayOf(DownloadMediaType.REMOTE_MEDIA.name), + flags = DownloadRequest.Flags.IS_DASH_PLAYLIST + ) + ) { + putBundle("dashOptions", Bundle().apply { + putLong("offsetTime", offsetTime) + putLong("duration", duration) + }) + } + } + + fun downloadMedia(mediaData: String, mediaType: DownloadMediaType, encryption: MediaEncryptionKeyPair? = null) { + sendToBroadcastReceiver( + DownloadRequest( + inputMedias = arrayOf(mediaData), + inputTypes = arrayOf(mediaType.name), + mediaEncryption = if (encryption != null) mapOf(mediaData to encryption) else mapOf() + ) + ) + } + + fun downloadMediaWithOverlay( + videoData: String, + overlayData: String, + videoType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, + overlayType: DownloadMediaType = DownloadMediaType.LOCAL_MEDIA, + videoEncryption: MediaEncryptionKeyPair? = null, + overlayEncryption: MediaEncryptionKeyPair? = null) + { + val encryptionMap = mutableMapOf<String, MediaEncryptionKeyPair>() + + if (videoEncryption != null) encryptionMap[videoData] = videoEncryption + if (overlayEncryption != null) encryptionMap[overlayData] = overlayEncryption + sendToBroadcastReceiver(DownloadRequest( + inputMedias = arrayOf(videoData, overlayData), + inputTypes = arrayOf(videoType.name, overlayType.name), + mediaEncryption = encryptionMap, + flags = DownloadRequest.Flags.SHOULD_MERGE_OVERLAY + )) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadTaskManager.kt @@ -0,0 +1,117 @@ +package me.rhunk.snapenhance.download + +import android.annotation.SuppressLint +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.enums.DownloadStage +import me.rhunk.snapenhance.util.SQLiteDatabaseHelper + +class DownloadTaskManager { + private lateinit var taskDatabase: SQLiteDatabase + private val cachedTasks = mutableMapOf<Int, PendingDownload>() + + @SuppressLint("Range") + fun init(context: Context) { + if (this::taskDatabase.isInitialized) return + taskDatabase = context.openOrCreateDatabase("download_tasks", Context.MODE_PRIVATE, null).apply { + SQLiteDatabaseHelper.createTablesFromSchema(this, mapOf( + "tasks" to listOf( + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "outputPath TEXT", + "outputFile TEXT", + "mediaDisplayType TEXT", + "mediaDisplaySource TEXT", + "iconUrl TEXT", + "downloadStage TEXT" + ) + )) + } + } + + fun addTask(task: PendingDownload): Int { + taskDatabase.execSQL("INSERT INTO tasks (outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf( + task.outputPath, + task.outputFile, + task.mediaDisplayType, + task.mediaDisplaySource, + task.iconUrl, + task.downloadStage.name + ) + ) + task.id = taskDatabase.rawQuery("SELECT last_insert_rowid()", null).use { + it.moveToFirst() + it.getInt(0) + } + cachedTasks[task.id] = task + return task.id + } + + fun updateTask(task: PendingDownload) { + taskDatabase.execSQL("UPDATE tasks SET outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", + arrayOf( + task.outputPath, + task.outputFile, + task.mediaDisplayType, + task.mediaDisplaySource, + task.iconUrl, + task.downloadStage.name, + task.id + ) + ) + cachedTasks[task.id] = task + } + + fun isEmpty(): Boolean { + return cachedTasks.isEmpty() + } + + private fun removeTask(id: Int) { + taskDatabase.execSQL("DELETE FROM tasks WHERE id = ?", arrayOf(id)) + cachedTasks.remove(id) + } + + fun removeTask(task: PendingDownload) { + removeTask(task.id) + } + + fun queryAllTasks(): Map<Int, PendingDownload> { + cachedTasks.putAll(queryTasks( + from = cachedTasks.values.lastOrNull()?.id ?: Int.MAX_VALUE, + amount = 20 + )) + return cachedTasks.toSortedMap(reverseOrder()) + } + + @SuppressLint("Range") + fun queryTasks(from: Int, amount: Int = 20): Map<Int, PendingDownload> { + val cursor = taskDatabase.rawQuery("SELECT * FROM tasks WHERE id < ? ORDER BY id DESC LIMIT ?", arrayOf(from.toString(), amount.toString())) + val result = sortedMapOf<Int, PendingDownload>() + + while (cursor.moveToNext()) { + val task = PendingDownload( + id = cursor.getInt(cursor.getColumnIndex("id")), + outputFile = cursor.getString(cursor.getColumnIndex("outputFile")), + outputPath = cursor.getString(cursor.getColumnIndex("outputPath")), + mediaDisplayType = cursor.getString(cursor.getColumnIndex("mediaDisplayType")), + mediaDisplaySource = cursor.getString(cursor.getColumnIndex("mediaDisplaySource")), + iconUrl = cursor.getString(cursor.getColumnIndex("iconUrl")) + ).apply { + downloadStage = DownloadStage.valueOf(cursor.getString(cursor.getColumnIndex("downloadStage"))) + //if downloadStage is not saved, it means the app was killed before the download was finished + if (downloadStage != DownloadStage.SAVED) { + downloadStage = DownloadStage.FAILED + } + } + result[task.id] = task + } + cursor.close() + return result.toSortedMap(reverseOrder()) + } + + fun removeAllTasks() { + taskDatabase.execSQL("DELETE FROM tasks") + cachedTasks.clear() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/MediaDownloadReceiver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/MediaDownloadReceiver.kt @@ -0,0 +1,329 @@ +package me.rhunk.snapenhance.download + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.MediaScannerConnection +import android.os.Handler +import android.widget.Toast +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.download.data.DownloadRequest +import me.rhunk.snapenhance.download.data.InputMedia +import me.rhunk.snapenhance.download.data.MediaEncryptionKeyPair +import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.enums.DownloadMediaType +import me.rhunk.snapenhance.download.enums.DownloadStage +import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import java.io.File +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.util.zip.ZipInputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import kotlin.coroutines.coroutineContext +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +data class DownloadedFile( + val file: File, + val fileType: FileType +) + +/** + * MediaDownloadReceiver handles the download of media files + */ +@OptIn(ExperimentalEncodingApi::class) +class MediaDownloadReceiver : BroadcastReceiver() { + companion object { + val downloadTaskManager = DownloadTaskManager() + const val DOWNLOAD_ACTION = "me.rhunk.snapenhance.download.MediaDownloadReceiver.DOWNLOAD_ACTION" + } + + private lateinit var context: Context + + private fun runOnUIThread(block: () -> Unit) { + Handler(context.mainLooper).post(block) + } + + private fun shortToast(text: String) { + runOnUIThread { + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + } + + private fun longToast(text: String) { + runOnUIThread { + Toast.makeText(context, text, Toast.LENGTH_LONG).show() + } + } + + private fun extractZip(inputStream: InputStream): List<File> { + val files = mutableListOf<File>() + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + + while (entry != null) { + createMediaTempFile().also { file -> + file.outputStream().use { outputStream -> + zipInputStream.copyTo(outputStream) + } + files += file + } + entry = zipInputStream.nextEntry + } + + return files + } + + private fun decryptInputStream(inputStream: InputStream, encryption: MediaEncryptionKeyPair): InputStream { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val key = Base64.UrlSafe.decode(encryption.key) + val iv = Base64.UrlSafe.decode(encryption.iv) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return CipherInputStream(inputStream, cipher) + } + + private fun createNeededDirectories(file: File): File { + val directory = file.parentFile ?: return file + if (!directory.exists()) { + directory.mkdirs() + } + return file + } + + private suspend fun saveMediaToGallery(inputFile: File, pendingDownload: PendingDownload) { + if (coroutineContext.job.isCancelled) return + + runCatching { + val fileType = FileType.fromFile(inputFile) + val outputFile = File(pendingDownload.outputPath + "." + fileType.fileExtension).also { createNeededDirectories(it) } + inputFile.copyTo(outputFile, overwrite = true) + + MediaScannerConnection.scanFile(context, arrayOf(outputFile.absolutePath), null, null) + + //print the path of the saved media + val parentName = outputFile.parentFile?.parentFile?.absolutePath?.let { + if (!it.endsWith("/")) "$it/" else it + } + + longToast("Saved media to ${outputFile.absolutePath.replace(parentName ?: "", "")}") + + pendingDownload.outputFile = outputFile.absolutePath + pendingDownload.downloadStage = DownloadStage.SAVED + }.onFailure { + Logger.error("Failed to save media to gallery", it) + longToast("Failed to save media to gallery") + pendingDownload.downloadStage = DownloadStage.FAILED + } + } + + private fun createMediaTempFile(): File { + return File.createTempFile("media", ".tmp") + } + + private fun downloadInputMedias(downloadRequest: DownloadRequest) = runBlocking { + val jobs = mutableListOf<Job>() + val downloadedMedias = mutableMapOf<InputMedia, File>() + + downloadRequest.getInputMedias().forEach { inputMedia -> + fun handleInputStream(inputStream: InputStream) { + createMediaTempFile().apply { + if (inputMedia.encryption != null) { + decryptInputStream(inputStream, inputMedia.encryption).use { decryptedInputStream -> + decryptedInputStream.copyTo(outputStream()) + } + } else { + inputStream.copyTo(outputStream()) + } + }.also { downloadedMedias[inputMedia] = it } + } + + launch { + when (inputMedia.type) { + DownloadMediaType.PROTO_MEDIA -> { + RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content))?.let { inputStream -> + handleInputStream(inputStream) + } + } + DownloadMediaType.DIRECT_MEDIA -> { + val decoded = Base64.UrlSafe.decode(inputMedia.content) + createMediaTempFile().apply { + writeBytes(decoded) + }.also { downloadedMedias[inputMedia] = it } + } + DownloadMediaType.REMOTE_MEDIA -> { + with(URL(inputMedia.content).openConnection() as HttpURLConnection) { + requestMethod = "GET" + setRequestProperty("User-Agent", Constants.USER_AGENT) + connect() + handleInputStream(inputStream) + } + } + else -> { + downloadedMedias[inputMedia] = File(inputMedia.content) + } + } + }.also { jobs.add(it) } + } + + jobs.joinAll() + downloadedMedias + } + + private suspend fun downloadRemoteMedia(pendingDownloadObject: PendingDownload, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) { + downloadRequest.getInputMedias().first().let { inputMedia -> + val mediaType = downloadRequest.getInputType(0)!! + val media = downloadedMedias[inputMedia]!! + + if (!downloadRequest.isDashPlaylist) { + saveMediaToGallery(media.file, pendingDownloadObject) + media.file.delete() + return + } + + assert(mediaType == DownloadMediaType.REMOTE_MEDIA) + + val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file) + val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") + for (i in 0 until baseUrlNodeList.length) { + val baseUrlNode = baseUrlNodeList.item(i) + val baseUrl = baseUrlNode.textContent + baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" + } + + val dashOptions = downloadRequest.getDashOptions()!! + + val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD) + val xmlData = dashPlaylistFile.outputStream() + TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) + + longToast("Downloading dash media...") + val outputFile = File.createTempFile("dash", ".mp4") + runCatching { + MediaDownloaderHelper.downloadDashChapterFile( + dashPlaylist = dashPlaylistFile, + output = outputFile, + startTime = dashOptions.offsetTime, + duration = dashOptions.duration) + saveMediaToGallery(outputFile, pendingDownloadObject) + }.onFailure { + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error("failed to download dash media", it) + longToast("Failed to download dash media: ${it.message}") + pendingDownloadObject.downloadStage = DownloadStage.FAILED + } + + dashPlaylistFile.delete() + outputFile.delete() + media.file.delete() + } + } + + private fun renameFromFileType(file: File, fileType: FileType): File { + val newFile = File(file.parentFile, file.nameWithoutExtension + "." + fileType.fileExtension) + file.renameTo(newFile) + return newFile + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != DOWNLOAD_ACTION) return + this.context = context + downloadTaskManager.init(context) + + val downloadRequest = DownloadRequest.fromBundle(intent.extras!!) + + GlobalScope.launch(Dispatchers.IO) { + val pendingDownloadObject = PendingDownload.fromBundle(intent.extras!!) + + downloadTaskManager.addTask(pendingDownloadObject) + pendingDownloadObject.apply { + job = coroutineContext.job + downloadStage = DownloadStage.DOWNLOADING + } + + runCatching { + //first download all input medias into cache + val downloadedMedias = downloadInputMedias(downloadRequest).map { + it.key to DownloadedFile(it.value, FileType.fromFile(it.value)) + }.toMap().toMutableMap() + + var shouldMergeOverlay = downloadRequest.shouldMergeOverlay + + //if there is a zip file, extract it and replace the downloaded media with the extracted ones + downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { entry -> + val extractedMedias = extractZip(entry.file.inputStream()).map { + InputMedia( + type = DownloadMediaType.LOCAL_MEDIA, + content = it.absolutePath + ) to DownloadedFile(it, FileType.fromFile(it)) + } + + downloadedMedias.values.removeIf { + it.file.delete() + true + } + + downloadedMedias.putAll(extractedMedias) + shouldMergeOverlay = true + } + + if (shouldMergeOverlay) { + assert(downloadedMedias.size == 2) + val media = downloadedMedias.values.first { it.fileType.isVideo } + val overlayMedia = downloadedMedias.values.first { it.fileType.isImage } + + val renamedMedia = renameFromFileType(media.file, media.fileType) + val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType) + val mergedOverlay: File = File.createTempFile("merged", "." + media.fileType.fileExtension) + runCatching { + longToast("Merging overlay...") + pendingDownloadObject.downloadStage = DownloadStage.MERGING + + MediaDownloaderHelper.mergeOverlayFile( + media = renamedMedia, + overlay = renamedOverlayMedia, + output = mergedOverlay + ) + + saveMediaToGallery(mergedOverlay, pendingDownloadObject) + }.onFailure { + if (coroutineContext.job.isCancelled) return@onFailure + Logger.error("failed to merge overlay", it) + longToast("Failed to merge overlay: ${it.message}") + pendingDownloadObject.downloadStage = DownloadStage.MERGE_FAILED + } + + mergedOverlay.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + } + + downloadRemoteMedia(pendingDownloadObject, downloadedMedias, downloadRequest) + }.onFailure { + pendingDownloadObject.downloadStage = DownloadStage.FAILED + Logger.error("failed to download media", it) + longToast("Failed to download media: ${it.message}") + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/DownloadRequest.kt @@ -0,0 +1,105 @@ +package me.rhunk.snapenhance.download.data + +import android.os.Bundle +import me.rhunk.snapenhance.download.enums.DownloadMediaType + + +data class DashOptions(val offsetTime: Long, val duration: Long?) +data class InputMedia( + val content: String, + val type: DownloadMediaType, + val encryption: MediaEncryptionKeyPair? = null +) + +class DownloadRequest( + private val outputPath: String = "", + private val inputMedias: Array<String>, + private val inputTypes: Array<String>, + private val mediaEncryption: Map<String, MediaEncryptionKeyPair> = emptyMap(), + private val flags: Int = 0, + private val dashOptions: Map<String, String?>? = null, + private val mediaDisplaySource: String? = null, + private val mediaDisplayType: String? = null +) { + companion object { + fun fromBundle(bundle: Bundle): DownloadRequest { + return DownloadRequest( + outputPath = bundle.getString("outputPath")!!, + mediaDisplaySource = bundle.getString("mediaDisplaySource"), + mediaDisplayType = bundle.getString("mediaDisplayType"), + inputMedias = bundle.getStringArray("inputMedias")!!, + inputTypes = bundle.getStringArray("inputTypes")!!, + mediaEncryption = bundle.getStringArray("mediaEncryption")?.associate { entry -> + entry.split("|").let { + it[0] to MediaEncryptionKeyPair(it[1], it[2]) + } + } ?: emptyMap(), + dashOptions = bundle.getBundle("dashOptions")?.let { options -> + options.keySet().associateWith { key -> + options.getString(key) + } + }, + flags = bundle.getInt("flags", 0) + ) + } + } + + fun toBundle(): Bundle { + return Bundle().apply { + putString("outputPath", outputPath) + putString("mediaDisplaySource", mediaDisplaySource) + putString("mediaDisplayType", mediaDisplayType) + putStringArray("inputMedias", inputMedias) + putStringArray("inputTypes", inputTypes) + putStringArray("mediaEncryption", mediaEncryption.map { entry -> + "${entry.key}|${entry.value.key}|${entry.value.iv}" + }.toTypedArray()) + putBundle("dashOptions", dashOptions?.let { bundle -> + Bundle().apply { + bundle.forEach { (key, value) -> + putString(key, value) + } + } + }) + putInt("flags", flags) + } + } + + object Flags { + const val SHOULD_MERGE_OVERLAY = 1 + const val IS_DASH_PLAYLIST = 2 + } + + val isDashPlaylist: Boolean + get() = flags and Flags.IS_DASH_PLAYLIST != 0 + + val shouldMergeOverlay: Boolean + get() = flags and Flags.SHOULD_MERGE_OVERLAY != 0 + + fun getDashOptions(): DashOptions? { + return dashOptions?.let { + DashOptions( + offsetTime = it["offsetTime"]?.toLong() ?: 0, + duration = it["duration"]?.toLong() + ) + } + } + + fun getInputMedia(index: Int): String? { + return inputMedias.getOrNull(index) + } + + fun getInputMedias(): List<InputMedia> { + return inputMedias.mapIndexed { index, uri -> + InputMedia( + content = uri, + type = DownloadMediaType.valueOf(inputTypes[index]), + encryption = mediaEncryption.getOrDefault(uri, null) + ) + } + } + + fun getInputType(index: Int): DownloadMediaType? { + return inputTypes.getOrNull(index)?.let { DownloadMediaType.valueOf(it) } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/MediaEncryptionKeyPair.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalEncodingApi::class) + +package me.rhunk.snapenhance.download.data + +import me.rhunk.snapenhance.data.wrapper.impl.media.EncryptionWrapper +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +// key and iv are base64 encoded +data class MediaEncryptionKeyPair( + val key: String, + val iv: String +) + +fun Pair<ByteArray, ByteArray>.toKeyPair(): MediaEncryptionKeyPair { + return MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second)) +} + +fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair { + return MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec)) +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/data/PendingDownload.kt @@ -0,0 +1,49 @@ +package me.rhunk.snapenhance.download.data + +import android.os.Bundle +import kotlinx.coroutines.Job +import me.rhunk.snapenhance.download.MediaDownloadReceiver +import me.rhunk.snapenhance.download.enums.DownloadStage + +data class PendingDownload( + var outputFile: String? = null, + var job: Job? = null, + + var id: Int = 0, + val outputPath: String, + val mediaDisplayType: String?, + val mediaDisplaySource: String?, + val iconUrl: String? +) { + companion object { + fun fromBundle(bundle: Bundle): PendingDownload { + return PendingDownload( + outputPath = bundle.getString("outputPath")!!, + mediaDisplayType = bundle.getString("mediaDisplayType"), + mediaDisplaySource = bundle.getString("mediaDisplaySource"), + iconUrl = bundle.getString("iconUrl") + ) + } + } + + var changeListener = { _: DownloadStage, _: DownloadStage -> } + private var _stage: DownloadStage = DownloadStage.PENDING + var downloadStage: DownloadStage + get() = synchronized(this) { + _stage + } + set(value) = synchronized(this) { + changeListener(_stage, value) + _stage = value + MediaDownloadReceiver.downloadTaskManager.updateTask(this) + } + + fun isJobActive(): Boolean { + return job?.isActive ?: false + } + + fun cancel() { + job?.cancel() + downloadStage = DownloadStage.CANCELLED + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadMediaType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadMediaType.kt @@ -0,0 +1,22 @@ +package me.rhunk.snapenhance.download.enums + +import android.net.Uri + +enum class DownloadMediaType { + PROTO_MEDIA, + DIRECT_MEDIA, + REMOTE_MEDIA, + LOCAL_MEDIA; + + companion object { + fun fromUri(uri: Uri): DownloadMediaType { + return when (uri.scheme) { + "proto" -> PROTO_MEDIA + "direct" -> DIRECT_MEDIA + "http", "https" -> REMOTE_MEDIA + "file" -> LOCAL_MEDIA + else -> throw IllegalArgumentException("Unknown uri scheme: ${uri.scheme}") + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadStage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/enums/DownloadStage.kt @@ -0,0 +1,14 @@ +package me.rhunk.snapenhance.download.enums + +enum class DownloadStage( + val isFinalStage: Boolean = false, +) { + PENDING(false), + DOWNLOADING(false), + MERGING(false), + DOWNLOADED(true), + SAVED(true), + MERGE_FAILED(true), + FAILED(true), + CANCELLED(true) +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt @@ -6,10 +6,13 @@ import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.hook.hook +import me.rhunk.snapenhance.util.getObjectField class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { lateinit var conversationManager: Any + var openedConversationUUID: SnapUUID? = null var lastOpenedConversationUUID: SnapUUID? = null var lastFetchConversationUserUUID: SnapUUID? = null var lastFetchConversationUUID: SnapUUID? = null @@ -22,24 +25,22 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C } override fun onActivityCreate() { + context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param -> + val userIdToConversation = (param.arg<ArrayList<*>>(0)) + .takeIf { it.isNotEmpty() } + ?.get(0) ?: return@hook + + lastFetchConversationUUID = SnapUUID(userIdToConversation.getObjectField("mConversationId")) + lastFetchConversationUserUUID = SnapUUID(userIdToConversation.getObjectField("mUserId")) + } + with(context.classCache.conversationManager) { Hooker.hook(this, "enterConversation", HookStage.BEFORE) { - lastOpenedConversationUUID = SnapUUID(it.arg(0)) - } - - Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param -> - val conversationIds: List<Any> = param.arg(0) - if (conversationIds.isNotEmpty()) { - lastFetchConversationUserUUID = SnapUUID(conversationIds[0]) - } + openedConversationUUID = SnapUUID(it.arg(0)) } Hooker.hook(this, "exitConversation", HookStage.BEFORE) { - lastOpenedConversationUUID = null - } - - Hooker.hook(this, "fetchConversation", HookStage.BEFORE) { - lastFetchConversationUUID = SnapUUID(it.arg(0)) + openedConversationUUID = null } } @@ -54,7 +55,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C //get last opened snap for media downloader Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param -> - lastOpenedConversationUUID = SnapUUID(param.arg(1)) + openedConversationUUID = SnapUUID(param.arg(1)) lastFocusedMessageId = param.arg(2) } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt @@ -3,11 +3,11 @@ package me.rhunk.snapenhance.features.impl.downloader import android.app.AlertDialog import android.content.DialogInterface import android.graphics.Bitmap -import android.media.MediaScannerConnection +import android.graphics.BitmapFactory import android.net.Uri import android.widget.ImageView import com.arthenica.ffmpegkit.FFmpegKit -import me.rhunk.snapenhance.Constants +import kotlinx.coroutines.runBlocking import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.config.ConfigProperty @@ -18,6 +18,10 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistIt import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap +import me.rhunk.snapenhance.database.objects.FriendInfo +import me.rhunk.snapenhance.download.ClientDownloadManager +import me.rhunk.snapenhance.download.data.toKeyPair +import me.rhunk.snapenhance.download.enums.DownloadMediaType import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.impl.Messaging @@ -25,33 +29,23 @@ import me.rhunk.snapenhance.features.impl.spying.MessageLogger import me.rhunk.snapenhance.hook.HookAdapter import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker -import me.rhunk.snapenhance.util.EncryptionUtils -import me.rhunk.snapenhance.util.MediaDownloaderHelper -import me.rhunk.snapenhance.util.MediaType -import me.rhunk.snapenhance.util.PreviewUtils -import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.util.snap.EncryptionHelper +import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.util.snap.MediaType +import me.rhunk.snapenhance.util.snap.PreviewUtils import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.protobuf.ProtoReader -import java.io.ByteArrayOutputStream +import me.rhunk.snapenhance.util.snap.BitmojiSelfie import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL import java.nio.file.Paths import java.text.SimpleDateFormat -import java.util.Arrays import java.util.Locale -import java.util.concurrent.atomic.AtomicReference -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult +import kotlin.coroutines.suspendCoroutine +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.path.inputStream - +@OptIn(ExperimentalEncodingApi::class) class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null private var lastSeenMapParams: ParamMap? = null @@ -59,12 +53,38 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam runCatching { FFmpegKit.execute("-version") }.isSuccess } + private fun provideClientDownloadManager( + pathSuffix: String, + mediaDisplaySource: String? = null, + mediaDisplayType: String? = null, + friendInfo: FriendInfo? = null + ): ClientDownloadManager { + val iconUrl = friendInfo?.takeIf { + it.bitmojiAvatarId != null && it.bitmojiSelfieId != null + }?.let { + BitmojiSelfie.getBitmojiSelfie(it.bitmojiSelfieId!!, it.bitmojiAvatarId!!, BitmojiSelfie.BitmojiSelfieType.THREE_D) + } + + val outputPath = File( + context.config.string(ConfigProperty.SAVE_FOLDER), + createNewFilePath(pathSuffix.hashCode(), pathSuffix) + ).absolutePath + + return ClientDownloadManager( + context = context, + mediaDisplaySource = mediaDisplaySource, + mediaDisplayType = mediaDisplayType, + iconUrl = iconUrl, + outputPath = outputPath + ) + } + private fun canMergeOverlay(): Boolean { if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["merge_overlay"] == false) return false return isFFmpegPresent } - private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String { + private fun createNewFilePath(hash: Int, pathPrefix: String): String { val hexHash = Integer.toHexString(hash) val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS) @@ -81,13 +101,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } if (downloadOptions["format_user_folder"] == true) { - finalPath.append(author).append("/") + finalPath.append(pathPrefix).append("/") } if (downloadOptions["format_hash"] == true) { appendFileName(hexHash) } if (downloadOptions["format_username"] == true) { - appendFileName(author) + appendFileName(pathPrefix) } if (downloadOptions["format_date_time"] == true) { appendFileName(currentDateTime) @@ -95,79 +115,9 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam if (finalPath.isEmpty()) finalPath.append(hexHash) - return finalPath.toString() + "." + fileType.fileExtension + return finalPath.toString() } - private fun downloadFile(outputFile: File, content: ByteArray): Boolean { - val onDownloadComplete = { - context.shortToast( - "Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "") - .substring(1) - ) - } - if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) { - try { - val fos = FileOutputStream(outputFile) - fos.write(content) - fos.close() - MediaScannerConnection.scanFile( - context.androidContext, - arrayOf(outputFile.absolutePath), - null, - null - ) - onDownloadComplete() - } catch (e: Throwable) { - xposedLog(e) - context.longToast("Failed to save file: " + e.message) - return false - } - return true - } - context.downloadServer.startFileDownload(outputFile, content) { result -> - if (result) { - onDownloadComplete() - return@startFileDownload - } - context.longToast("Failed to save file. Check logs for more info.") - } - return true - } - private fun queryMediaData(mediaInfo: MediaInfo): ByteArray { - val mediaUri = Uri.parse(mediaInfo.uri) - val mediaInputStream = AtomicReference<InputStream>() - if (mediaUri.scheme == "file") { - mediaInputStream.set(Paths.get(mediaUri.path).inputStream()) - } else { - val url = URL(mediaUri.toString()) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.setRequestProperty("User-Agent", Constants.USER_AGENT) - connection.connect() - mediaInputStream.set(connection.inputStream) - } - mediaInfo.encryption?.let { encryption -> - mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE))) - } - return mediaInputStream.get().readBytes() - } - - private fun createNeededDirectories(file: File): File { - val directory = file.parentFile ?: return file - if (!directory.exists()) { - directory.mkdirs() - } - return file - } - - private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean { - val fileName: String = createNewFilePath(hash, author, fileType) - val outputFile: File = - createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) - return outputFile.exists() - } - - /* * Download the last seen media */ @@ -178,41 +128,46 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam } } - private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) { - if (mediaInfoMap.isEmpty()) return - val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! - if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { - context.shortToast("Downloading split snap") - } - var mediaContent: ByteArray? = queryMediaData(originalMediaInfo) - val hash = Arrays.hashCode(mediaContent) - if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { - //prevent converting the same media twice - if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) { - context.shortToast("Media already exists") - return + private fun handleLocalReferences(path: String) = runBlocking { + Uri.parse(path).let { uri -> + if (uri.scheme == "file") { + return@let suspendCoroutine<String> { continuation -> + context.downloadServer.ensureServerStarted { + val url = putDownloadableContent(Paths.get(uri.path).inputStream()) + continuation.resumeWith(Result.success(url)) + } + } } - val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!! - val overlayContent: ByteArray = queryMediaData(overlayMediaInfo) - mediaContent = MediaDownloaderHelper.mergeOverlay(mediaContent, overlayContent, false) + path } - val fileType = FileType.fromByteArray(mediaContent!!) - downloadMediaContent(mediaContent, hash, author, fileType) } - private fun downloadMediaContent( - data: ByteArray, - hash: Int, - messageAuthor: String, - fileType: FileType - ): Boolean { - val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false - val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) - if (outputFile.exists()) { - context.shortToast("Media already exists") - return false + private fun downloadOperaMedia(clientDownloadManager: ClientDownloadManager, mediaInfoMap: Map<MediaType, MediaInfo>) { + if (mediaInfoMap.isEmpty()) return + + val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! + val overlay = mediaInfoMap[MediaType.OVERLAY] + + val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) + val overlayReference = overlay?.let { handleLocalReferences(it.uri) } + + overlay?.let { + clientDownloadManager.downloadMediaWithOverlay( + originalMediaInfoReference, + overlayReference!!, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + DownloadMediaType.fromUri(Uri.parse(overlayReference)), + videoEncryption = originalMediaInfo.encryption?.toKeyPair(), + overlayEncryption = overlay.encryption?.toKeyPair() + ) + return } - return downloadFile(outputFile, data) + + clientDownloadManager.downloadMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + originalMediaInfo.encryption?.toKeyPair() + ) } /** @@ -236,8 +191,9 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam return } - val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!! - downloadOperaMedia(mediaInfoMap, author) + val author = context.database.getFriendInfo(senderId) ?: return + val authorUsername = author.usernameForSorting!! + downloadOperaMedia(provideClientDownloadManager(authorUsername, authorUsername, "Chat Media", friendInfo = author), mediaInfoMap) return } @@ -250,9 +206,10 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam storyIdStartIndex, playlistGroup.indexOf(",", storyIdStartIndex) ) - val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) + val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) ?: return + val authorName = author.usernameForSorting!! - downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!) + downloadOperaMedia(provideClientDownloadManager(authorName, authorName, "Story", friendInfo = author), mediaInfoMap, ) return } @@ -264,13 +221,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( "[^\\x00-\\x7F]".toRegex(), "") - downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName") + downloadOperaMedia(provideClientDownloadManager("Public-Stories/$userDisplayName", userDisplayName, "Public Story"), mediaInfoMap) return } //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { - downloadOperaMedia(mediaInfoMap, "Spotlight") + downloadOperaMedia(provideClientDownloadManager("Spotlight", mediaDisplayType = "Spotlight", mediaDisplaySource = paramMap["TIME_STAMP"].toString()), mediaInfoMap) return } @@ -302,24 +259,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam //get the mpd playlist and append the cdn url to baseurl nodes val playlistUrl = paramMap["MEDIA_ID"].toString().let { it.substring(it.indexOf("https://cf-st.sc-cdn.net")) } - val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(URL(playlistUrl).openStream()) - val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") - for (i in 0 until baseUrlNodeList.length) { - val baseUrlNode = baseUrlNodeList.item(i) - val baseUrl = baseUrlNode.textContent - baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl" - } - - val xmlData = ByteArrayOutputStream() - TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData)) - runCatching { - context.shortToast("Downloading dash media. This might take a while...") - val downloadedMedia = MediaDownloaderHelper.downloadDashChapter(xmlData.toByteArray().toString(Charsets.UTF_8), snapChapterTimestamp, duration) - downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), "Pro-Stories/${storyName}", FileType.fromByteArray(downloadedMedia)) - }.onFailure { - context.longToast("Failed to download media: ${it.message}") - xposedLog(it) - } + val clientDownloadManager = provideClientDownloadManager("Pro-Stories/${storyName}", storyName, "Pro Story") + clientDownloadManager.downloadDashMedia( + playlistUrl, + snapChapterTimestamp, + duration + ) } } @@ -383,60 +328,103 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam fun onMessageActionMenu(isPreviewMode: Boolean) { //check if the message was focused in a conversation val messaging = context.feature(Messaging::class) - if (messaging.lastOpenedConversationUUID == null) return + val messageLogger = context.feature(MessageLogger::class) + + if (messaging.openedConversationUUID == null) return val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return //get the message author - val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!! + val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!)!! + val authorName = friendInfo.usernameForSorting!! + + var messageContent = message.message_content!! + var isArroyoMessage = true + var deletedMediaReference: ByteArray? = null //check if the messageId - val contentType: ContentType = ContentType.fromId(message.content_type) - if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) { - context.shortToast("Preview/Download are not yet available for deleted messages") - return + var contentType: ContentType = ContentType.fromId(message.content_type) + + if (messageLogger.isMessageRemoved(message.client_message_id.toLong())) { + val messageObject = messageLogger.getMessageObject(message.client_conversation_id!!, message.client_message_id.toLong()) ?: throw Exception("Message not found in database") + isArroyoMessage = false + val messageContentObject = messageObject.getAsJsonObject("mMessageContent") + + messageContent = messageContentObject + .getAsJsonArray("mContent") + .map { it.asByte } + .toByteArray() + + contentType = ContentType.valueOf(messageContentObject + .getAsJsonPrimitive("mContentType").asString + ) + + deletedMediaReference = messageContentObject.getAsJsonArray("mRemoteMediaReferences") + .map { it.asJsonObject.getAsJsonArray("mMediaReferences") } + .flatten().let { reference -> + if (reference.isEmpty()) return@let null + reference[0].asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray() + } } + if (contentType != ContentType.NOTE && contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) { context.shortToast("Unsupported content type $contentType") return } - val messageReader = ProtoReader(message.message_content!!) - val urlProto: ByteArray = messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!! - - //download the message content - try { - val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(urlProto, canMergeOverlay(), isPreviewMode) { - EncryptionUtils.decryptInputStreamFromArroyo(it, contentType, messageReader) - }[MediaType.ORIGINAL] ?: throw Exception("Failed to download media") - val fileType = FileType.fromByteArray(downloadedMedia) - - if (isPreviewMode) { - runCatching { - val bitmap: Bitmap? = PreviewUtils.createPreview(downloadedMedia, fileType.isVideo) - if (bitmap == null) { - context.shortToast("Failed to create preview") - return - } - val builder = AlertDialog.Builder(context.mainActivity) - builder.setTitle("Preview") - val imageView = ImageView(builder.context) - imageView.setImageBitmap(bitmap) - builder.setView(imageView) - builder.setPositiveButton( - "Close" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - context.runOnUiThread { builder.show() } - }.onFailure { - context.shortToast("Failed to create preview: $it") - xposedLog(it) - } + + val messageReader = ProtoReader(messageContent) + val urlProto: ByteArray = if (isArroyoMessage) { + messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!! + } else { + deletedMediaReference!! + } + + runCatching { + if (!isPreviewMode) { + val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) + provideClientDownloadManager(authorName, authorName, "Chat Media", friendInfo = friendInfo).downloadMedia( + Base64.UrlSafe.encode(urlProto), + DownloadMediaType.PROTO_MEDIA, + encryption = encryptionKeys?.toKeyPair() + ) return } - downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), messageAuthor, fileType) - } catch (e: Throwable) { - context.longToast("Failed to download " + e.message) - xposedLog(e) + + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) { + EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage) + } + + runCatching { + val originalMedia = downloadedMediaList[MediaType.ORIGINAL] ?: return + val overlay = downloadedMediaList[MediaType.OVERLAY] + + var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + + if (bitmap == null) { + context.shortToast("Failed to create preview") + return + } + + overlay?.let { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + + with(AlertDialog.Builder(context.mainActivity)) { + setTitle("Preview") + setView(ImageView(context).apply { + setImageBitmap(bitmap) + }) + setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() } + this@MediaDownloader.context.runOnUiThread { show() } + } + }.onFailure { + context.shortToast("Failed to create preview: $it") + xposedLog(it) + } + }.onFailure { + context.longToast("Failed to download " + it.message) + xposedLog(it) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spying/MessageLogger.kt @@ -37,6 +37,15 @@ class MessageLogger : Feature("MessageLogger", context.bridgeClient.deleteMessageLoggerMessage(conversationId, messageId) } + fun getMessageObject(conversationId: String, messageId: Long): JsonObject? { + if (deletedMessageCache.containsKey(messageId)) { + return deletedMessageCache[messageId] + } + return context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let { + JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject + } + } + @OptIn(ExperimentalTime::class) override fun asyncOnActivityCreate() { ConfigProperty.MESSAGE_LOGGER.valueContainer.addPropertyChangeListener { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/AutoSave.kt @@ -69,8 +69,8 @@ class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CR if (context.config.options(ConfigProperty.AUTO_SAVE_MESSAGES).none { it.value }) return false with(context.feature(Messaging::class)) { - if (lastOpenedConversationUUID == null) return@canSave false - val conversation = lastOpenedConversationUUID.toString() + if (openedConversationUUID == null) return@canSave false + val conversation = openedConversationUUID.toString() if (context.feature(StealthMode::class).isStealth(conversation)) return@canSave false if (context.feature(AntiAutoSave::class).isConversationIgnored(conversation)) return@canSave false } @@ -120,7 +120,7 @@ class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CR val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() runCatching { fetchConversationWithMessagesPaginatedMethod.invoke( - messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instanceNonNull(), + messaging.conversationManager, messaging.openedConversationUUID!!.instanceNonNull(), Long.MAX_VALUE, 3, callback diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/GalleryMediaSendOverride.kt @@ -40,7 +40,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara } "NOTE" -> { localMessageContent.contentType = ContentType.NOTE - val mediaDuration = messageProtoReader.getInt(3, 3, 5, 1, 1, 15) ?: 0 + val mediaDuration = messageProtoReader.getLong(3, 3, 5, 1, 1, 15) ?: 0 localMessageContent.content = MessageSender.audioNoteProto(mediaDuration) } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/tweaks/Notifications.kt @@ -7,6 +7,7 @@ import android.app.RemoteInput import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Bundle import android.os.UserHandle import de.robv.android.xposed.XposedBridge @@ -24,10 +25,10 @@ import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.util.CallbackBuilder -import me.rhunk.snapenhance.util.EncryptionUtils -import me.rhunk.snapenhance.util.MediaDownloaderHelper -import me.rhunk.snapenhance.util.MediaType -import me.rhunk.snapenhance.util.PreviewUtils +import me.rhunk.snapenhance.util.snap.EncryptionHelper +import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper +import me.rhunk.snapenhance.util.snap.MediaType +import me.rhunk.snapenhance.util.snap.PreviewUtils import me.rhunk.snapenhance.util.protobuf.ProtoReader class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { @@ -161,7 +162,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN notificationDataQueue.entries.onEach { (messageId, notificationData) -> val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return - val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage") + val senderUsername by lazy { + context.database.getFriendInfo(snapMessage.senderId.toString())?.let { + it.displayName ?: it.username + } + } val contentType = snapMessage.messageContent.contentType val contentData = snapMessage.messageContent.content @@ -192,21 +197,17 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) runCatching { - //download the media - val mediaInfo = ProtoReader(contentData).let { - if (contentType == ContentType.EXTERNAL_MEDIA) - return@let it.readPath(*Constants.MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH) - else - return@let it.readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH) - }?: return@runCatching - - val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference, mergeOverlay = false, isPreviewMode = false) { - if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) - EncryptionUtils.decryptInputStream(it, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX) - else it - }[MediaType.ORIGINAL] ?: throw Throwable("Failed to download media") - - val bitmapPreview = PreviewUtils.createPreview(downloadedMedia, mediaType.name.contains("VIDEO"))!! + val messageReader = ProtoReader(contentData) + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { + EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false) + } + + var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[MediaType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!! + + downloadedMediaList[MediaType.OVERLAY]?.let { + bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size)) + } + val notificationBuilder = XposedHelpers.newInstance( Notification.Builder::class.java, context.androidContext, diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt @@ -1,9 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import me.rhunk.snapenhance.ModContext - -abstract class AbstractMenu() { - lateinit var context: ModContext - - open fun init() {} -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MapActivity.kt @@ -1,99 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.app.Activity -import android.app.AlertDialog -import android.content.Context -import android.os.Bundle -import android.view.MotionEvent -import android.widget.Button -import android.widget.EditText -import me.rhunk.snapenhance.R -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.MapView -import org.osmdroid.views.Projection -import org.osmdroid.views.overlay.Marker -import org.osmdroid.views.overlay.Overlay - - -//TODO: Implement correctly -class MapActivity : Activity() { - - private lateinit var mapView: MapView - - @SuppressLint("MissingInflatedId", "ResourceType") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val contextBundle = intent.extras?.getBundle("location") ?: return - val locationLatitude = contextBundle.getDouble("latitude") - val locationLongitude = contextBundle.getDouble("longitude") - - Configuration.getInstance().load(applicationContext, getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) - - setContentView(R.layout.map) - - mapView = findViewById(R.id.mapView) - mapView.setMultiTouchControls(true); - mapView.setTileSource(TileSourceFactory.MAPNIK) - - val startPoint = GeoPoint(locationLatitude, locationLongitude) - mapView.controller.setZoom(10.0) - mapView.controller.setCenter(startPoint) - - val marker = Marker(mapView) - marker.isDraggable = true - marker.position = startPoint - marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - - mapView.overlays.add(object: Overlay() { - override fun onSingleTapConfirmed(e: MotionEvent?, mapView: MapView?): Boolean { - val proj: Projection = mapView!!.projection - val loc = proj.fromPixels(e!!.x.toInt(), e.y.toInt()) as GeoPoint - marker.position = loc - mapView.invalidate() - return true - } - }) - - mapView.overlays.add(marker) - - val applyButton = findViewById<Button>(R.id.apply_location_button) - applyButton.setOnClickListener { - val bundle = Bundle() - bundle.putFloat("latitude", marker.position.latitude.toFloat()) - bundle.putFloat("longitude", marker.position.longitude.toFloat()) - setResult(RESULT_OK, intent.putExtra("location", bundle)) - finish() - } - - val setPreciseLocationButton = findViewById<Button>(R.id.set_precise_location_button) - - setPreciseLocationButton.setOnClickListener { - val locationDialog = layoutInflater.inflate(R.layout.precise_location_dialog, null) - val dialogLatitude = locationDialog.findViewById<EditText>(R.id.dialog_latitude).also { it.setText(marker.position.latitude.toString()) } - val dialogLongitude = locationDialog.findViewById<EditText>(R.id.dialog_longitude).also { it.setText(marker.position.longitude.toString()) } - - AlertDialog.Builder(this) - .setView(locationDialog) - .setTitle("Set a precise location") - .setPositiveButton("Set") { _, _ -> - val latitude = dialogLatitude.text.toString().toDoubleOrNull() - val longitude = dialogLongitude.text.toString().toDoubleOrNull() - if (latitude != null && longitude != null) { - val preciseLocation = GeoPoint(latitude, longitude) - mapView.controller.setCenter(preciseLocation) - marker.position = preciseLocation - mapView.invalidate() - } - }.setNegativeButton("Cancel") { _, _ -> }.show() - } - } - - override fun onDestroy() { - super.onDestroy() - mapView.onDetach() - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt @@ -1,147 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.Feature -import me.rhunk.snapenhance.features.FeatureLoadParams -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.ui.menus.impl.ChatActionMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.FriendFeedInfoMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.OperaContextActionMenu -import me.rhunk.snapenhance.features.impl.ui.menus.impl.SettingsMenu -import me.rhunk.snapenhance.hook.HookStage -import me.rhunk.snapenhance.hook.Hooker -import java.lang.reflect.Modifier - -@SuppressLint("DiscouragedApi") -class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { - private val friendFeedInfoMenu = FriendFeedInfoMenu() - private val operaContextActionMenu = OperaContextActionMenu() - private val chatActionMenu = ChatActionMenu() - private val settingMenu = SettingsMenu() - - private val newChatString by lazy { - context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) - } - - private fun wasInjectedView(view: View): Boolean { - if (view.getTag(Constants.VIEW_INJECTED_CODE) != null) return true - view.setTag(Constants.VIEW_INJECTED_CODE, true) - return false - } - - @SuppressLint("ResourceType") - override fun asyncOnActivityCreate() { - friendFeedInfoMenu.context = context - operaContextActionMenu.context = context - chatActionMenu.context = context - settingMenu.context = context - - val messaging = context.feature(Messaging::class) - - val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val actionSheetContainer = context.resources.getIdentifier("action_sheet_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) - val actionMenu = context.resources.getIdentifier("action_menu", "id", Constants.SNAPCHAT_PACKAGE_NAME) - - val addViewMethod = ViewGroup::class.java.getMethod( - "addView", - View::class.java, - Int::class.javaPrimitiveType, - ViewGroup.LayoutParams::class.java - ) - - Hooker.hook(addViewMethod, HookStage.BEFORE) { param -> - val viewGroup: ViewGroup = param.thisObject() - val originalAddView: (View) -> Unit = { - param.invokeOriginal(arrayOf(it, -1, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )) - ) - } - - val childView: View = param.arg(0) - operaContextActionMenu.inject(viewGroup, childView) - - //download in chat snaps and notes from the chat action menu - if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) { - if (viewGroup.parent == null || viewGroup.parent.parent == null) return@hook - chatActionMenu.inject(viewGroup) - return@hook - } - - //inject in group chat menus - if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchConversationUserUUID == null) { - val injectedLayout = LinearLayout(childView.context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.BOTTOM - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - addView(childView) - } - - Hooker.ephemeralHook(context.classCache.conversationManager, "fetchConversation", HookStage.AFTER) { - if (wasInjectedView(injectedLayout)) return@ephemeralHook - - context.runOnUiThread { - val viewList = mutableListOf<View>() - friendFeedInfoMenu.inject(injectedLayout) { view -> - view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 10, 0, 10) - } - viewList.add(view) - } - viewList.reversed().forEach { injectedLayout.addView(it, 0) } - } - } - - param.setArg(0, injectedLayout) - } - - if (viewGroup is LinearLayout && viewGroup.id == actionSheetItemsContainerLayoutId) { - val itemStringInterface by lazy { - childView.javaClass.declaredFields.filter { - !it.type.isPrimitive && Modifier.isAbstract(it.type.modifiers) - }.map { - runCatching { - it.isAccessible = true - it[childView] - }.getOrNull() - }.firstOrNull() - } - - //the 3 dot button shows a menu which contains the first item as a Plain object - if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=$newChatString")) { - settingMenu.inject(viewGroup, originalAddView) - viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - context.config.writeConfig() - } - }) - return@hook - } - if (messaging.lastFetchConversationUserUUID == null) return@hook - - //filter by the slot index - if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook - friendFeedInfoMenu.inject(viewGroup, originalAddView) - - viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) {} - override fun onViewDetachedFromWindow(v: View) { - messaging.lastFetchConversationUserUUID = null - } - }) - } - - } - } - -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt @@ -1,93 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus - -import android.annotation.SuppressLint -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.graphics.drawable.ShapeDrawable -import android.graphics.drawable.StateListDrawable -import android.view.Gravity -import android.view.View -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.Constants - -object ViewAppearanceHelper { - @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", - "ClickableViewAccessibility" - ) - private var sigColorTextPrimary: Int = 0 - private var sigColorBackgroundSurface: Int = 0 - - private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { - if (!hasRadius) return ColorDrawable(color) - //FIXME: hardcoded radius - return ShapeDrawable().apply { - paint.color = color - shape = android.graphics.drawable.shapes.RoundRectShape( - floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), - null, - null - ) - } - } - - @SuppressLint("DiscouragedApi") - fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false) { - val resources = component.context.resources - if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { - with(component.context.theme) { - sigColorTextPrimary = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - - sigColorBackgroundSurface = obtainStyledAttributes( - intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) - ).getColor(0, 0) - } - } - - val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android") - val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 - - with(component) { - if (this is TextView) { - setTextColor(sigColorTextPrimary) - setShadowLayer(0F, 0F, 0F, 0) - gravity = Gravity.CENTER_VERTICAL - componentWidth?.let { width = it} - height = (150 * scalingFactor).toInt() - isAllCaps = false - textSize = 16f - typeface = resources.getFont(snapchatFontResId) - outlineProvider = null - setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) - } - background = StateListDrawable().apply { - addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, hasRadius)) - addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, hasRadius)) - } - } - - if (component is Switch) { - with(resources) { - component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() - } - component.trackTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#1d1d1d"), - Color.parseColor("#26bd49") - ) - ) - component.thumbTintList = ColorStateList( - arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) - ), intArrayOf( - Color.parseColor("#F5F5F5"), - Color.parseColor("#26bd49") - ) - ) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt @@ -1,94 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.os.SystemClock -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Button -import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper - - -class ChatActionMenu : AbstractMenu() { - private fun wasInjectedView(view: View): Boolean { - if (view.getTag(VIEW_INJECTED_CODE) != null) return true - view.setTag(VIEW_INJECTED_CODE, true) - return false - } - - @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup) { - val parent = viewGroup.parent.parent as ViewGroup - if (wasInjectedView(parent)) return - //close the action menu using a touch event - val closeActionMenu = { - viewGroup.dispatchTouchEvent( - MotionEvent.obtain( - SystemClock.uptimeMillis(), - SystemClock.uptimeMillis(), - MotionEvent.ACTION_DOWN, - 0f, - 0f, - 0 - ) - ) - } - - val injectButton = { button: Button -> - ViewAppearanceHelper.applyTheme(button, parent.width, hasRadius = true) - - with(button) { - layoutParams = MarginLayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(40, 0, 40, 15) - } - parent.addView(this) - } - } - - if (context.config.bool(ConfigProperty.CHAT_DOWNLOAD_CONTEXT_MENU)) { - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.preview_button") - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { this@ChatActionMenu.context.feature(MediaDownloader::class).onMessageActionMenu(true) } - } - }) - - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.download_button") - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { - this@ChatActionMenu.context.feature( - MediaDownloader::class - ).onMessageActionMenu(false) - } - } - }) - } - - //delete logged message button - if (context.config.bool(ConfigProperty.MESSAGE_LOGGER)) { - injectButton(Button(viewGroup.context).apply { - text = this@ChatActionMenu.context.translation.get("chat_action_menu.delete_logged_message_button") - setOnClickListener { - closeActionMenu() - this@ChatActionMenu.context.executeAsync { - with(this@ChatActionMenu.context.feature(Messaging::class)) { - context.feature(me.rhunk.snapenhance.features.impl.spying.MessageLogger::class).deleteMessage(lastOpenedConversationUUID.toString(), lastFocusedMessageId) - } - } - } - }) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt @@ -1,372 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.content.DialogInterface -import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.CompoundButton -import android.widget.LinearLayout -import android.widget.Switch -import android.widget.Toast -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.data.wrapper.impl.FriendActionButton -import me.rhunk.snapenhance.database.objects.ConversationMessage -import me.rhunk.snapenhance.database.objects.FriendInfo -import me.rhunk.snapenhance.database.objects.UserConversationLink -import me.rhunk.snapenhance.features.impl.Messaging -import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload -import me.rhunk.snapenhance.features.impl.spying.StealthMode -import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper -import java.net.HttpURLConnection -import java.net.URL -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale - -class FriendFeedInfoMenu : AbstractMenu() { - private fun getImageDrawable(url: String): Drawable { - val connection = URL(url).openConnection() as HttpURLConnection - connection.connect() - val input = connection.inputStream - return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) - } - - private fun formatDate(timestamp: Long): String? { - return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) - } - - private fun showProfileInfo(profile: FriendInfo) { - var icon: Drawable? = null - try { - if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { - icon = getImageDrawable( - "https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId - .toString() + "-" + profile.bitmojiAvatarId - .toString() + "-v1.webp?transparent=1&scale=0" - ) - } - } catch (e: Throwable) { - Logger.xposedLog(e) - } - val finalIcon = icon - context.runOnUiThread { - val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) - val builder = AlertDialog.Builder(context.mainActivity) - builder.setIcon(finalIcon) - builder.setTitle(profile.displayName) - - val birthday = Calendar.getInstance() - birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 - val message: String = """ - ${context.translation.get("profile_info.username")}: ${profile.username} - ${context.translation.get("profile_info.display_name")}: ${profile.displayName} - ${context.translation.get("profile_info.added_date")}: ${formatDate(addedTimestamp)} - ${birthday.getDisplayName( - Calendar.MONTH, - Calendar.LONG, - context.translation.locale - )?.let { - context.translation.get("profile_info.birthday") - .replace("{month}", it) - .replace("{day}", profile.birthday.toInt().toString()) - } - } - """.trimIndent() - builder.setMessage(message) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - builder.show() - } - } - - private fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) { - //query message - val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId( - conversationId, - context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH) - )?.reversed() - - if (messages.isNullOrEmpty()) { - Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show() - return - } - val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! - .map { context.database.getFriendInfo(it)!! } - .associateBy { it.userId!! } - - val messageBuilder = StringBuilder() - - messages.forEach{ message: ConversationMessage -> - val sender: FriendInfo? = participants[message.sender_id] - - var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name - - if (message.content_type == ContentType.SNAP.id) { - val readTimeStamp: Long = message.read_timestamp - messageString = "\uD83D\uDFE5" //red square - if (readTimeStamp > 0) { - messageString += " \uD83D\uDC40 " //eyes - messageString += DateFormat.getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT - ).format(Date(readTimeStamp)) - } - } - - var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation.get("conversation_preview.unknown_user") - - if (displayUsername.length > 12) { - displayUsername = displayUsername.substring(0, 13) + "... " - } - - messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") - } - - val targetPerson: FriendInfo? = - if (userId == null) null else participants[userId] - - targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { - val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() - messageBuilder.append("\n\n") - .append("\uD83D\uDD25 ") //fire emoji - .append(context.translation.get("conversation_preview.streak_expiration").format( - timeSecondDiff / 60 / 24, - timeSecondDiff / 60 % 24, - timeSecondDiff % 60 - )) - } - - //alert dialog - val builder = AlertDialog.Builder(context.mainActivity) - builder.setTitle(context.translation.get("conversation_preview.title")) - builder.setMessage(messageBuilder.toString()) - builder.setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } - targetPerson?.let { - builder.setNegativeButton(context.translation.get("modal_option.profile_info")) {_, _ -> - context.executeAsync { - showProfileInfo(it) - } - } - } - builder.show() - } - - private fun createEmojiDrawable(text: String, width: Int, height: Int, textSize: Float, disabled: Boolean = false): Drawable { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - val paint = Paint() - paint.textSize = textSize - paint.color = Color.BLACK - paint.textAlign = Paint.Align.CENTER - canvas.drawText(text, width / 2f, height.toFloat() - paint.descent(), paint) - if (disabled) { - paint.color = Color.RED - paint.strokeWidth = 5f - canvas.drawLine(0f, 0f, width.toFloat(), height.toFloat(), paint) - } - return BitmapDrawable(context.resources, bitmap) - } - - private fun getCurrentConversationId(): Pair<String, String?> { - val messaging = context.feature(Messaging::class) - val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString() - - val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) { - val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found") - conversation.client_conversation_id!!.trim().lowercase() - } else { - messaging.lastFetchConversationUUID.toString() - } - - return Pair(conversationId, focusedConversationTargetUser) - } - - private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { - val switch = Switch(context.androidContext) - switch.text = context.translation.get(text) - switch.isChecked = isChecked() - ViewAppearanceHelper.applyTheme(switch) - switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> - toggle(checked) - } - viewConsumer(switch) - } - - @SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale", "InflateParams", - "DiscouragedApi", "ClickableViewAccessibility" - ) - fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { - val modContext = context - - val friendFeedMenuOptions = context.config.options(ConfigProperty.FRIEND_FEED_MENU_BUTTONS) - if (friendFeedMenuOptions.none { it.value }) return - - val (conversationId, targetUser) = getCurrentConversationId() - - if (!context.config.bool(ConfigProperty.ENABLE_FRIEND_FEED_MENU_BAR)) { - //preview button - val previewButton = Button(viewModel.context).apply { - text = modContext.translation.get("friend_menu_option.preview") - ViewAppearanceHelper.applyTheme(this, viewModel.width) - setOnClickListener { - showPreview( - targetUser, - conversationId, - context - ) - } - } - - //stealth switch - val stealthSwitch = Switch(viewModel.context).apply { - text = modContext.translation.get("friend_menu_option.stealth_mode") - isChecked = modContext.feature(StealthMode::class).isStealth(conversationId) - ViewAppearanceHelper.applyTheme(this) - setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> - modContext.feature(StealthMode::class).setStealth( - conversationId, - isChecked - ) - } - } - - if (friendFeedMenuOptions["anti_auto_save"] == true) { - createToggleFeature(viewConsumer, - "friend_menu_option.anti_auto_save", - { context.feature(AntiAutoSave::class).isConversationIgnored(conversationId) }, - { context.feature(AntiAutoSave::class).setConversationIgnored(conversationId, it) } - ) - } - - run { - val userId = context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId ?: return@run - if (friendFeedMenuOptions["auto_download_blacklist"] == true) { - createToggleFeature(viewConsumer, - "friend_menu_option.auto_download_blacklist", - { context.feature(AntiAutoDownload::class).isUserIgnored(userId) }, - { context.feature(AntiAutoDownload::class).setUserIgnored(userId, it) } - ) - } - } - - if (friendFeedMenuOptions["stealth_mode"] == true) { - viewConsumer(stealthSwitch) - } - if (friendFeedMenuOptions["conversation_info"] == true) { - viewConsumer(previewButton) - } - return - } - - val menuButtonBar = LinearLayout(viewModel.context).apply { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER - } - - fun createActionButton(icon: String, isDisabled: Boolean? = null, onClick: (Boolean) -> Unit) { - //FIXME: hardcoded values - menuButtonBar.addView(LinearLayout(viewModel.context).apply { - layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) - gravity = Gravity.CENTER - isClickable = false - - var isLineThrough = isDisabled ?: false - FriendActionButton.new(viewModel.context).apply { - fun setLineThrough(value: Boolean) { - setIconDrawable(createEmojiDrawable(icon, 60, 60, 50f, if (isDisabled == null) false else value)) - } - setLineThrough(isLineThrough) - (instanceNonNull() as View).apply { - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 40, 0, 40) - } - setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_UP) { - isLineThrough = !isLineThrough - onClick(isLineThrough) - setLineThrough(isLineThrough) - } - false - } - } - - }.also { addView(it.instanceNonNull() as View) } - - }) - } - - if (friendFeedMenuOptions["auto_download_blacklist"] == true) { - run { - val userId = - context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId - ?: return@run - createActionButton( - "\u2B07\uFE0F", - isDisabled = !context.feature(AntiAutoDownload::class).isUserIgnored(userId) - ) { - context.feature(AntiAutoDownload::class).setUserIgnored(userId, !it) - } - } - } - - if (friendFeedMenuOptions["anti_auto_save"] == true) { - //diskette - createActionButton("\uD83D\uDCAC", - isDisabled = !context.feature(AntiAutoSave::class) - .isConversationIgnored(conversationId) - ) { - context.feature(AntiAutoSave::class).setConversationIgnored(conversationId, !it) - } - } - - - if (friendFeedMenuOptions["stealth_mode"] == true) { - //eyes - createActionButton( - "\uD83D\uDC7B", - isDisabled = !context.feature(StealthMode::class).isStealth(conversationId) - ) { isChecked -> - context.feature(StealthMode::class).setStealth( - conversationId, - !isChecked - ) - } - } - - if (friendFeedMenuOptions["conversation_info"] == true) { - //user - createActionButton("\uD83D\uDC64") { - showPreview( - targetUser, - conversationId, - viewModel.context - ) - } - } - - viewConsumer(menuButtonBar) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt @@ -1,82 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.ScrollView -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme - -@SuppressLint("DiscouragedApi") -class OperaContextActionMenu : AbstractMenu() { - private val contextCardsScrollView by lazy { - context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) - } - - /* - LinearLayout : - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - - LinearLayout: - - SnapFontTextView - - ImageView - */ - private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { - if (viewGroup !is LinearLayout) return false - val children = ArrayList<View>() - for (i in 0 until viewGroup.getChildCount()) - children.add(viewGroup.getChildAt(i)) - return if (children.any { view: View? -> view !is LinearLayout }) - false - else children.map { view: View -> view as LinearLayout } - .any { linearLayout: LinearLayout -> - val viewChildren = ArrayList<View>() - for (i in 0 until linearLayout.childCount) viewChildren.add( - linearLayout.getChildAt( - i - ) - ) - viewChildren.any { viewChild: View -> - viewChild.javaClass.name.endsWith("SnapFontTextView") - } - } - } - - @SuppressLint("SetTextI18n") - fun inject(viewGroup: ViewGroup, childView: View) { - try { - if (viewGroup.parent !is ScrollView) return - val parent = viewGroup.parent as ScrollView - if (parent.id != contextCardsScrollView) return - if (childView !is LinearLayout) return - if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return - - val linearLayout = LinearLayout(childView.getContext()) - linearLayout.orientation = LinearLayout.VERTICAL - linearLayout.gravity = Gravity.CENTER - linearLayout.layoutParams = - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - val button = Button(childView.getContext()) - button.text = context.translation.get("opera_context_menu.download") - button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } - applyTheme(button) - linearLayout.addView(button) - (childView as ViewGroup).addView(linearLayout, 0) - } catch (e: Throwable) { - Logger.xposedLog(e) - } - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt @@ -1,242 +0,0 @@ -package me.rhunk.snapenhance.features.impl.ui.menus.impl - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.graphics.Color -import android.graphics.Typeface -import android.text.InputType -import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.Switch -import android.widget.TextView -import me.rhunk.snapenhance.BuildConfig -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.config.ConfigProperty -import me.rhunk.snapenhance.config.impl.ConfigIntegerValue -import me.rhunk.snapenhance.config.impl.ConfigStateListValue -import me.rhunk.snapenhance.config.impl.ConfigStateSelection -import me.rhunk.snapenhance.config.impl.ConfigStateValue -import me.rhunk.snapenhance.config.impl.ConfigStringValue -import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu -import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper - -class SettingsMenu : AbstractMenu() { - @SuppressLint("ClickableViewAccessibility") - private fun createCategoryTitle(key: String): TextView { - val categoryText = TextView(context.androidContext) - categoryText.text = context.translation.get(key) - ViewAppearanceHelper.applyTheme(categoryText) - categoryText.textSize = 20f - categoryText.typeface = categoryText.typeface?.let { Typeface.create(it, Typeface.BOLD) } - categoryText.setOnTouchListener { _, _ -> true } - return categoryText - } - - @SuppressLint("SetTextI18n") - private fun createPropertyView(property: ConfigProperty): View { - val propertyName = context.translation.get(property.nameKey) - val updateButtonText: (TextView, String) -> Unit = { textView, text -> - textView.text = "$propertyName${if (text.isEmpty()) "" else ": $text"}" - } - - val updateLocalizedText: (TextView, String) -> Unit = { textView, value -> - updateButtonText(textView, value.let { - if (it.isEmpty()) { - "(empty)" - } - else { - if (property.disableValueLocalization) { - it - } else { - context.translation.get("option." + property.nameKey + "." + it) - } - } - }) - } - - val textEditor: ((String) -> Unit) -> Unit = { updateValue -> - val builder = AlertDialog.Builder(context.mainActivity!!) - builder.setTitle(propertyName) - - val input = EditText(context.androidContext) - input.inputType = InputType.TYPE_CLASS_TEXT - input.setText(property.valueContainer.value().toString()) - - builder.setView(input) - builder.setPositiveButton("OK") { _, _ -> - updateValue(input.text.toString()) - } - - builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } - builder.show() - } - - val resultView: View = when (property.valueContainer) { - is ConfigStringValue -> { - val textView = TextView(context.androidContext) - updateButtonText(textView, property.valueContainer.let { - if (it.isHidden) it.hiddenValue() - else it.value() - }) - ViewAppearanceHelper.applyTheme(textView) - textView.setOnClickListener { - textEditor { value -> - property.valueContainer.writeFrom(value) - updateButtonText(textView, property.valueContainer.let { - if (it.isHidden) it.hiddenValue() - else it.value() - }) - } - } - textView - } - is ConfigIntegerValue -> { - val button = Button(context.androidContext) - updateButtonText(button, property.valueContainer.value().toString()) - button.setOnClickListener { - textEditor { value -> - runCatching { - property.valueContainer.writeFrom(value) - updateButtonText(button, value) - }.onFailure { - context.shortToast("Invalid value") - } - } - } - ViewAppearanceHelper.applyTheme(button) - button - } - is ConfigStateValue -> { - val switch = Switch(context.androidContext) - switch.text = propertyName - switch.isChecked = property.valueContainer.value() - switch.setOnCheckedChangeListener { _, isChecked -> - property.valueContainer.writeFrom(isChecked.toString()) - } - ViewAppearanceHelper.applyTheme(switch) - switch - } - is ConfigStateSelection -> { - val button = Button(context.androidContext) - updateLocalizedText(button, property.valueContainer.value()) - - button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(context.mainActivity!!) - builder.setTitle(propertyName) - - builder.setSingleChoiceItems( - property.valueContainer.keys().toTypedArray().map { - if (property.disableValueLocalization) it - else context.translation.get("option." + property.nameKey + "." + it) - }.toTypedArray(), - property.valueContainer.keys().indexOf(property.valueContainer.value()) - ) { _, which -> - property.valueContainer.writeFrom(property.valueContainer.keys()[which]) - } - - builder.setPositiveButton("OK") { _, _ -> - updateLocalizedText(button, property.valueContainer.value()) - } - - builder.show() - } - ViewAppearanceHelper.applyTheme(button) - button - } - is ConfigStateListValue -> { - val button = Button(context.androidContext) - updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") - - button.setOnClickListener {_ -> - val builder = AlertDialog.Builder(context.mainActivity!!) - builder.setTitle(propertyName) - - val sortedStates = property.valueContainer.value().toSortedMap() - - builder.setMultiChoiceItems( - sortedStates.toSortedMap().map { - if (property.disableValueLocalization) it.key - else context.translation.get("option." + property.nameKey + "." + it.key) - }.toTypedArray(), - sortedStates.map { it.value }.toBooleanArray() - ) { _, which, isChecked -> - sortedStates.keys.toList()[which].let { key -> - property.valueContainer.setKey(key, isChecked) - } - } - - builder.setPositiveButton("OK") { _, _ -> - updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") - } - - builder.show() - } - ViewAppearanceHelper.applyTheme(button) - button - } - else -> { - TextView(context.androidContext) - } - } - return resultView - } - - private fun newSeparator(thickness: Int, color: Int = Color.BLACK): View { - return LinearLayout(context.mainActivity).apply { - setPadding(0, 0, 0, thickness) - setBackgroundColor(color) - } - } - - @SuppressLint("SetTextI18n") - @Suppress("deprecation") - fun inject(viewModel: View, addView: (View) -> Unit) { - val packageInfo = viewModel.context.packageManager.getPackageInfo(Constants.SNAPCHAT_PACKAGE_NAME, 0) - val versionTextBuilder = StringBuilder() - versionTextBuilder.append("SnapEnhance ").append(BuildConfig.VERSION_NAME) - .append(" by rhunk") - if (BuildConfig.DEBUG) { - versionTextBuilder.append("\n").append("Snapchat ").append(packageInfo.versionName) - .append(" (").append(packageInfo.longVersionCode).append(")") - } - val titleText = TextView(viewModel.context) - titleText.text = versionTextBuilder.toString() - ViewAppearanceHelper.applyTheme(titleText) - titleText.textSize = 18f - titleText.minHeight = 80 * versionTextBuilder.chars().filter { ch: Int -> ch == '\n'.code } - .count().coerceAtLeast(2).toInt() - addView(titleText) - - val actions = context.actionManager.getActions().map { - Pair(it) { - val button = Button(viewModel.context) - button.text = context.translation.get(it.nameKey) - button.setOnClickListener { _ -> - it.run() - } - ViewAppearanceHelper.applyTheme(button) - button - } - } - - context.config.entries().groupBy { - it.key.category - }.forEach { (category, value) -> - addView(createCategoryTitle(category.key)) - value.filter { it.key.shouldAppearInSettings }.forEach { (property, _) -> - addView(createPropertyView(property)) - actions.find { pair -> pair.first.dependsOnProperty == property}?.let { pair -> - addView(pair.second()) - } - } - } - - actions.filter { it.first.dependsOnProperty == null }.forEach { - addView(it.second()) - } - - addView(newSeparator(3, Color.parseColor("#f5f5f5"))) - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt @@ -31,7 +31,7 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks import me.rhunk.snapenhance.features.impl.ui.PinConversations import me.rhunk.snapenhance.features.impl.ui.UITweaks -import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector +import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector import me.rhunk.snapenhance.manager.Manager import java.util.concurrent.Executors import kotlin.reflect.KClass diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadListAdapter.kt @@ -0,0 +1,186 @@ +package me.rhunk.snapenhance.ui.download + +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Handler +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.download.data.PendingDownload +import me.rhunk.snapenhance.download.enums.DownloadStage +import me.rhunk.snapenhance.util.snap.PreviewUtils +import java.io.File +import java.net.URL +import kotlin.concurrent.thread + +class DownloadListAdapter( + private val downloadList: MutableList<PendingDownload> +): Adapter<DownloadListAdapter.ViewHolder>() { + private val previewJobs = mutableMapOf<Int, Job>() + + inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { + val bitmojiIcon: ImageView = view.findViewById(R.id.bitmoji_icon) + val title: TextView = view.findViewById(R.id.item_title) + val subtitle: TextView = view.findViewById(R.id.item_subtitle) + val status: TextView = view.findViewById(R.id.item_status) + val actionButton: Button = view.findViewById(R.id.item_action_button) + val radius by lazy { + view.context.resources.getDimensionPixelSize(R.dimen.download_manager_item_preview_radius) + } + val viewWidth by lazy { + view.resources.displayMetrics.widthPixels + } + val viewHeight by lazy { + view.layoutParams.height + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.download_manager_item, parent, false)) + } + + override fun onViewRecycled(holder: ViewHolder) { + val download = downloadList.getOrNull(holder.bindingAdapterPosition) ?: return + + previewJobs[holder.hashCode()]?.let { + it.cancel() + previewJobs.remove(holder.hashCode()) + } + } + + override fun getItemCount(): Int { + return downloadList.size + } + + @OptIn(DelicateCoroutinesApi::class) + private fun handlePreview(download: PendingDownload, holder: ViewHolder) { + download.outputFile?.let { File(it) }?.takeIf { it.exists() }?.let { + GlobalScope.launch(Dispatchers.IO) { + val previewBitmap = PreviewUtils.createPreviewFromFile(it, 1F)?.let { preview -> + val offsetY = (preview.height / 2 - holder.viewHeight / 2).coerceAtLeast(0) + + Bitmap.createScaledBitmap( + Bitmap.createBitmap(preview, 0, offsetY, + preview.width.coerceAtMost(holder.viewWidth), + preview.height.coerceAtMost(holder.viewHeight) + ), + holder.viewWidth, + holder.viewHeight, + false + ) + }?: return@launch + + if (coroutineContext.job.isCancelled) return@launch + Handler(holder.view.context.mainLooper).post { + holder.view.background = RoundedBitmapDrawableFactory.create(holder.view.context.resources, previewBitmap).also { + it.cornerRadius = holder.radius.toFloat() + } + } + }.also { job -> + previewJobs[holder.hashCode()] = job + } + } + } + + private fun updateViewHolder(download: PendingDownload, holder: ViewHolder) { + holder.status.text = download.downloadStage.toString() + holder.view.background = holder.view.context.getDrawable(R.drawable.download_manager_item_background) + + handlePreview(download, holder) + + val isSaved = download.downloadStage == DownloadStage.SAVED + //if the download is in progress, the user can cancel it + val canInteract = if (download.job != null) !download.downloadStage.isFinalStage || isSaved + else isSaved + + holder.status.visibility = if (isSaved) View.GONE else View.VISIBLE + + with(holder.actionButton) { + isEnabled = canInteract + alpha = if (canInteract) 1f else 0.5f + background = context.getDrawable(if (isSaved) R.drawable.action_button_success else R.drawable.action_button_cancel) + setTextColor(context.getColor(if (isSaved) R.color.successColor else R.color.actionBarColor)) + text = if (isSaved) "Open" else "Cancel" + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val pendingDownload = downloadList[position] + + pendingDownload.changeListener = { _, _ -> + Handler(holder.view.context.mainLooper).post { + updateViewHolder(pendingDownload, holder) + notifyItemChanged(position) + } + } + + holder.bitmojiIcon.visibility = View.GONE + + pendingDownload.iconUrl?.let { url -> + thread(start = true) { + runCatching { + val iconBitmap = URL(url).openStream().use { + BitmapFactory.decodeStream(it) + } + Handler(holder.view.context.mainLooper).post { + holder.bitmojiIcon.setImageBitmap(iconBitmap) + holder.bitmojiIcon.visibility = View.VISIBLE + } + } + } + } + + holder.title.visibility = View.GONE + holder.subtitle.visibility = View.GONE + + pendingDownload.mediaDisplayType?.let { + holder.title.text = it + holder.title.visibility = View.VISIBLE + } + + pendingDownload.mediaDisplaySource?.let { + holder.subtitle.text = it + holder.subtitle.visibility = View.VISIBLE + } + + holder.actionButton.setOnClickListener { + if (pendingDownload.downloadStage != DownloadStage.SAVED) { + pendingDownload.cancel() + pendingDownload.downloadStage = DownloadStage.CANCELLED + updateViewHolder(pendingDownload, holder) + notifyItemChanged(position); + return@setOnClickListener + } + + pendingDownload.outputFile?.let { + val file = File(it) + if (!file.exists()) { + Toast.makeText(holder.view.context, "File does not exist", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + val intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(Uri.parse(it), FileType.fromFile(File(it)).mimeType) + holder.view.context.startActivity(intent) + } + } + + updateViewHolder(pendingDownload, holder) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/download/DownloadManagerActivity.kt @@ -0,0 +1,156 @@ +package me.rhunk.snapenhance.ui.download + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.view.View +import android.widget.Button +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import me.rhunk.snapenhance.R +import me.rhunk.snapenhance.download.MediaDownloadReceiver +import me.rhunk.snapenhance.download.data.PendingDownload + +class DownloadManagerActivity : Activity() { + private val fetchedDownloadTasks = mutableListOf<PendingDownload>() + + private val preferences by lazy { + getSharedPreferences("settings", Context.MODE_PRIVATE) + } + + private fun updateNoDownloadText() { + findViewById<View>(R.id.no_download_title).let { + it.visibility = if (fetchedDownloadTasks.isEmpty()) View.VISIBLE else View.GONE + } + } + + @SuppressLint("BatteryLife", "NotifyDataSetChanged") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val downloadTaskManager = MediaDownloadReceiver.downloadTaskManager.also { it.init(this) } + + actionBar?.apply { + title = "Download Manager" + setBackgroundDrawable(ColorDrawable(getColor(R.color.actionBarColor))) + } + setContentView(R.layout.download_manager_activity) + + fetchedDownloadTasks.addAll(downloadTaskManager.queryAllTasks().values) + + + with(findViewById<RecyclerView>(R.id.download_list)) { + adapter = DownloadListAdapter(fetchedDownloadTasks).apply { + registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + updateNoDownloadText() + } + }) + } + + layoutManager = androidx.recyclerview.widget.LinearLayoutManager(this@DownloadManagerActivity) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val download = fetchedDownloadTasks[viewHolder.absoluteAdapterPosition] + return if (download.isJobActive()) { + 0 + } else { + super.getMovementFlags(recyclerView, viewHolder) + } + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + return false + } + + @SuppressLint("NotifyDataSetChanged") + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + fetchedDownloadTasks.removeAt(viewHolder.absoluteAdapterPosition).let { + downloadTaskManager.removeTask(it) + } + adapter?.notifyItemRemoved(viewHolder.absoluteAdapterPosition) + } + }).attachToRecyclerView(this) + + var isLoading = false + + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + val layoutManager = recyclerView.layoutManager as androidx.recyclerview.widget.LinearLayoutManager + val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() + + if (lastVisibleItemPosition == RecyclerView.NO_POSITION) { + return + } + + if (lastVisibleItemPosition == fetchedDownloadTasks.size - 1 && !isLoading) { + isLoading = true + + downloadTaskManager.queryTasks(fetchedDownloadTasks.last().id).forEach { + fetchedDownloadTasks.add(it.value) + adapter?.notifyItemInserted(fetchedDownloadTasks.size - 1) + } + + isLoading = false + } + } + }) + + with(this@DownloadManagerActivity.findViewById<Button>(R.id.remove_all_button)) { + setOnClickListener { + downloadTaskManager.removeAllTasks() + fetchedDownloadTasks.removeIf { + if (it.isJobActive()) it.cancel() + true + } + adapter?.notifyDataSetChanged() + updateNoDownloadText() + } + } + } + + updateNoDownloadText() + + if (preferences.getBoolean("ask_battery_optimisations", true)) { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + with(Intent()) { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = Uri.parse("package:$packageName") + startActivityForResult(this, 1) + } + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == 1) { + preferences.edit().putBoolean("ask_battery_optimisations", false).apply() + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onResume() { + super.onResume() + fetchedDownloadTasks.clear() + fetchedDownloadTasks.addAll(MediaDownloadReceiver.downloadTaskManager.queryAllTasks().values) + + with(findViewById<RecyclerView>(R.id.download_list)) { + adapter?.notifyDataSetChanged() + } + updateNoDownloadText() + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/map/MapActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/map/MapActivity.kt @@ -0,0 +1,98 @@ +package me.rhunk.snapenhance.ui.map + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.os.Bundle +import android.view.MotionEvent +import android.widget.Button +import android.widget.EditText +import me.rhunk.snapenhance.R +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.Projection +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Overlay + + +class MapActivity : Activity() { + + private lateinit var mapView: MapView + + @SuppressLint("MissingInflatedId", "ResourceType") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val contextBundle = intent.extras?.getBundle("location") ?: return + val locationLatitude = contextBundle.getDouble("latitude") + val locationLongitude = contextBundle.getDouble("longitude") + + Configuration.getInstance().load(applicationContext, getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) + + setContentView(R.layout.map) + + mapView = findViewById(R.id.mapView) + mapView.setMultiTouchControls(true); + mapView.setTileSource(TileSourceFactory.MAPNIK) + + val startPoint = GeoPoint(locationLatitude, locationLongitude) + mapView.controller.setZoom(10.0) + mapView.controller.setCenter(startPoint) + + val marker = Marker(mapView) + marker.isDraggable = true + marker.position = startPoint + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + + mapView.overlays.add(object: Overlay() { + override fun onSingleTapConfirmed(e: MotionEvent?, mapView: MapView?): Boolean { + val proj: Projection = mapView!!.projection + val loc = proj.fromPixels(e!!.x.toInt(), e.y.toInt()) as GeoPoint + marker.position = loc + mapView.invalidate() + return true + } + }) + + mapView.overlays.add(marker) + + val applyButton = findViewById<Button>(R.id.apply_location_button) + applyButton.setOnClickListener { + val bundle = Bundle() + bundle.putFloat("latitude", marker.position.latitude.toFloat()) + bundle.putFloat("longitude", marker.position.longitude.toFloat()) + setResult(RESULT_OK, intent.putExtra("location", bundle)) + finish() + } + + val setPreciseLocationButton = findViewById<Button>(R.id.set_precise_location_button) + + setPreciseLocationButton.setOnClickListener { + val locationDialog = layoutInflater.inflate(R.layout.precise_location_dialog, null) + val dialogLatitude = locationDialog.findViewById<EditText>(R.id.dialog_latitude).also { it.setText(marker.position.latitude.toString()) } + val dialogLongitude = locationDialog.findViewById<EditText>(R.id.dialog_longitude).also { it.setText(marker.position.longitude.toString()) } + + AlertDialog.Builder(this) + .setView(locationDialog) + .setTitle("Set a precise location") + .setPositiveButton("Set") { _, _ -> + val latitude = dialogLatitude.text.toString().toDoubleOrNull() + val longitude = dialogLongitude.text.toString().toDoubleOrNull() + if (latitude != null && longitude != null) { + val preciseLocation = GeoPoint(latitude, longitude) + mapView.controller.setCenter(preciseLocation) + marker.position = preciseLocation + mapView.invalidate() + } + }.setNegativeButton("Cancel") { _, _ -> }.show() + } + } + + override fun onDestroy() { + super.onDestroy() + mapView.onDetach() + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/AbstractMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/AbstractMenu.kt @@ -0,0 +1,9 @@ +package me.rhunk.snapenhance.ui.menu + +import me.rhunk.snapenhance.ModContext + +abstract class AbstractMenu() { + lateinit var context: ModContext + + open fun init() {} +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/ViewAppearanceHelper.kt @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.ui.menu + +import android.annotation.SuppressLint +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.view.Gravity +import android.view.View +import android.widget.Switch +import android.widget.TextView +import me.rhunk.snapenhance.Constants + +object ViewAppearanceHelper { + @SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded", "DiscouragedApi", + "ClickableViewAccessibility" + ) + private var sigColorTextPrimary: Int = 0 + private var sigColorBackgroundSurface: Int = 0 + + private fun createRoundedBackground(color: Int, hasRadius: Boolean): Drawable { + if (!hasRadius) return ColorDrawable(color) + //FIXME: hardcoded radius + return ShapeDrawable().apply { + paint.color = color + shape = android.graphics.drawable.shapes.RoundRectShape( + floatArrayOf(20f, 20f, 20f, 20f, 20f, 20f, 20f, 20f), + null, + null + ) + } + } + + @SuppressLint("DiscouragedApi") + fun applyTheme(component: View, componentWidth: Int? = null, hasRadius: Boolean = false) { + val resources = component.context.resources + if (sigColorBackgroundSurface == 0 || sigColorTextPrimary == 0) { + with(component.context.theme) { + sigColorTextPrimary = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorTextPrimary", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + + sigColorBackgroundSurface = obtainStyledAttributes( + intArrayOf(resources.getIdentifier("sigColorBackgroundSurface", "attr", Constants.SNAPCHAT_PACKAGE_NAME)) + ).getColor(0, 0) + } + } + + val snapchatFontResId = resources.getIdentifier("avenir_next_medium", "font", "com.snapchat.android") + val scalingFactor = resources.displayMetrics.densityDpi.toDouble() / 400 + + with(component) { + if (this is TextView) { + setTextColor(sigColorTextPrimary) + setShadowLayer(0F, 0F, 0F, 0) + gravity = Gravity.CENTER_VERTICAL + componentWidth?.let { width = it} + height = (150 * scalingFactor).toInt() + isAllCaps = false + textSize = 16f + typeface = resources.getFont(snapchatFontResId) + outlineProvider = null + setPadding((40 * scalingFactor).toInt(), 0, (40 * scalingFactor).toInt(), 0) + } + background = StateListDrawable().apply { + addState(intArrayOf(), createRoundedBackground(color = sigColorBackgroundSurface, hasRadius)) + addState(intArrayOf(android.R.attr.state_pressed), createRoundedBackground(color = 0x5395026, hasRadius)) + } + } + + if (component is Switch) { + with(resources) { + component.switchMinWidth = getDimension(getIdentifier("v11_switch_min_width", "dimen", Constants.SNAPCHAT_PACKAGE_NAME)).toInt() + } + component.trackTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#1d1d1d"), + Color.parseColor("#26bd49") + ) + ) + component.thumbTintList = ColorStateList( + arrayOf(intArrayOf(-android.R.attr.state_checked), intArrayOf(android.R.attr.state_checked) + ), intArrayOf( + Color.parseColor("#F5F5F5"), + Color.parseColor("#26bd49") + ) + ) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/ChatActionMenu.kt @@ -0,0 +1,95 @@ +package me.rhunk.snapenhance.ui.menu.impl + +import android.annotation.SuppressLint +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Button +import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.features.impl.spying.MessageLogger +import me.rhunk.snapenhance.ui.menu.AbstractMenu +import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper + + +class ChatActionMenu : AbstractMenu() { + private fun wasInjectedView(view: View): Boolean { + if (view.getTag(VIEW_INJECTED_CODE) != null) return true + view.setTag(VIEW_INJECTED_CODE, true) + return false + } + + @SuppressLint("SetTextI18n") + fun inject(viewGroup: ViewGroup) { + val parent = viewGroup.parent.parent as ViewGroup + if (wasInjectedView(parent)) return + //close the action menu using a touch event + val closeActionMenu = { + viewGroup.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 0f, + 0f, + 0 + ) + ) + } + + val injectButton = { button: Button -> + ViewAppearanceHelper.applyTheme(button, parent.width, hasRadius = true) + + with(button) { + layoutParams = MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(40, 0, 40, 15) + } + parent.addView(this) + } + } + + if (context.config.bool(ConfigProperty.CHAT_DOWNLOAD_CONTEXT_MENU)) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation.get("chat_action_menu.preview_button") + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { this@ChatActionMenu.context.feature(MediaDownloader::class).onMessageActionMenu(true) } + } + }) + + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation.get("chat_action_menu.download_button") + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { + this@ChatActionMenu.context.feature( + MediaDownloader::class + ).onMessageActionMenu(false) + } + } + }) + } + + //delete logged message button + if (context.config.bool(ConfigProperty.MESSAGE_LOGGER)) { + injectButton(Button(viewGroup.context).apply { + text = this@ChatActionMenu.context.translation.get("chat_action_menu.delete_logged_message_button") + setOnClickListener { + closeActionMenu() + this@ChatActionMenu.context.executeAsync { + with(this@ChatActionMenu.context.feature(Messaging::class)) { + context.feature(MessageLogger::class).deleteMessage(openedConversationUUID.toString(), lastFocusedMessageId) + } + } + } + }) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/FriendFeedInfoMenu.kt @@ -0,0 +1,375 @@ +package me.rhunk.snapenhance.ui.menu.impl + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CompoundButton +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.Toast +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.wrapper.impl.FriendActionButton +import me.rhunk.snapenhance.database.objects.ConversationMessage +import me.rhunk.snapenhance.database.objects.FriendInfo +import me.rhunk.snapenhance.database.objects.UserConversationLink +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload +import me.rhunk.snapenhance.features.impl.spying.StealthMode +import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave +import me.rhunk.snapenhance.ui.menu.AbstractMenu +import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper +import me.rhunk.snapenhance.util.snap.BitmojiSelfie +import java.net.HttpURLConnection +import java.net.URL +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class FriendFeedInfoMenu : AbstractMenu() { + private fun getImageDrawable(url: String): Drawable { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connect() + val input = connection.inputStream + return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) + } + + private fun formatDate(timestamp: Long): String? { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) + } + + private fun showProfileInfo(profile: FriendInfo) { + var icon: Drawable? = null + try { + if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { + icon = getImageDrawable( + BitmojiSelfie.getBitmojiSelfie( + profile.bitmojiSelfieId.toString(), + profile.bitmojiAvatarId.toString(), + BitmojiSelfie.BitmojiSelfieType.THREE_D + ) + ) + } + } catch (e: Throwable) { + Logger.xposedLog(e) + } + val finalIcon = icon + context.runOnUiThread { + val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) + val builder = AlertDialog.Builder(context.mainActivity) + builder.setIcon(finalIcon) + builder.setTitle(profile.displayName ?: profile.username) + + val birthday = Calendar.getInstance() + birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 + val message: String = """ + ${context.translation.get("profile_info.username")}: ${profile.username} + ${context.translation.get("profile_info.display_name")}: ${profile.displayName} + ${context.translation.get("profile_info.added_date")}: ${formatDate(addedTimestamp)} + ${birthday.getDisplayName( + Calendar.MONTH, + Calendar.LONG, + context.translation.locale + )?.let { + context.translation.get("profile_info.birthday") + .replace("{month}", it) + .replace("{day}", profile.birthday.toInt().toString()) + } + } + """.trimIndent() + builder.setMessage(message) + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + builder.show() + } + } + + private fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) { + //query message + val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId( + conversationId, + context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH) + )?.reversed() + + if (messages.isNullOrEmpty()) { + Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show() + return + } + val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! + .map { context.database.getFriendInfo(it)!! } + .associateBy { it.userId!! } + + val messageBuilder = StringBuilder() + + messages.forEach{ message: ConversationMessage -> + val sender: FriendInfo? = participants[message.sender_id] + + var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name + + if (message.content_type == ContentType.SNAP.id) { + val readTimeStamp: Long = message.read_timestamp + messageString = "\uD83D\uDFE5" //red square + if (readTimeStamp > 0) { + messageString += " \uD83D\uDC40 " //eyes + messageString += DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(readTimeStamp)) + } + } + + var displayUsername = sender?.displayName ?: sender?.usernameForSorting?: context.translation.get("conversation_preview.unknown_user") + + if (displayUsername.length > 12) { + displayUsername = displayUsername.substring(0, 13) + "... " + } + + messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") + } + + val targetPerson: FriendInfo? = + if (userId == null) null else participants[userId] + + targetPerson?.streakExpirationTimestamp?.takeIf { it > 0 }?.let { + val timeSecondDiff = ((it - System.currentTimeMillis()) / 1000 / 60).toInt() + messageBuilder.append("\n\n") + .append("\uD83D\uDD25 ") //fire emoji + .append(context.translation.get("conversation_preview.streak_expiration").format( + timeSecondDiff / 60 / 24, + timeSecondDiff / 60 % 24, + timeSecondDiff % 60 + )) + } + + //alert dialog + val builder = AlertDialog.Builder(context.mainActivity) + builder.setTitle(context.translation.get("conversation_preview.title")) + builder.setMessage(messageBuilder.toString()) + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + targetPerson?.let { + builder.setNegativeButton(context.translation.get("modal_option.profile_info")) {_, _ -> + context.executeAsync { + showProfileInfo(it) + } + } + } + builder.show() + } + + private fun createEmojiDrawable(text: String, width: Int, height: Int, textSize: Float, disabled: Boolean = false): Drawable { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint() + paint.textSize = textSize + paint.color = Color.BLACK + paint.textAlign = Paint.Align.CENTER + canvas.drawText(text, width / 2f, height.toFloat() - paint.descent(), paint) + if (disabled) { + paint.color = Color.RED + paint.strokeWidth = 5f + canvas.drawLine(0f, 0f, width.toFloat(), height.toFloat(), paint) + } + return BitmapDrawable(context.resources, bitmap) + } + + private fun getCurrentConversationId(): Pair<String, String?> { + val messaging = context.feature(Messaging::class) + val focusedConversationTargetUser: String? = messaging.lastFetchConversationUserUUID?.toString() + + val conversationId = if (messaging.lastFetchConversationUUID == null && focusedConversationTargetUser != null) { + val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: throw IllegalStateException("No conversation found") + conversation.client_conversation_id!!.trim().lowercase() + } else { + messaging.lastFetchConversationUUID.toString() + } + + return Pair(conversationId, focusedConversationTargetUser) + } + + private fun createToggleFeature(viewConsumer: ((View) -> Unit), text: String, isChecked: () -> Boolean, toggle: (Boolean) -> Unit) { + val switch = Switch(context.androidContext) + switch.text = context.translation.get(text) + switch.isChecked = isChecked() + ViewAppearanceHelper.applyTheme(switch) + switch.setOnCheckedChangeListener { _: CompoundButton?, checked: Boolean -> + toggle(checked) + } + viewConsumer(switch) + } + + @SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale", "InflateParams", + "DiscouragedApi", "ClickableViewAccessibility" + ) + fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { + val modContext = context + + val friendFeedMenuOptions = context.config.options(ConfigProperty.FRIEND_FEED_MENU_BUTTONS) + if (friendFeedMenuOptions.none { it.value }) return + + val (conversationId, targetUser) = getCurrentConversationId() + + if (!context.config.bool(ConfigProperty.ENABLE_FRIEND_FEED_MENU_BAR)) { + //preview button + val previewButton = Button(viewModel.context).apply { + text = modContext.translation.get("friend_menu_option.preview") + ViewAppearanceHelper.applyTheme(this, viewModel.width) + setOnClickListener { + showPreview( + targetUser, + conversationId, + context + ) + } + } + + //stealth switch + val stealthSwitch = Switch(viewModel.context).apply { + text = modContext.translation.get("friend_menu_option.stealth_mode") + isChecked = modContext.feature(StealthMode::class).isStealth(conversationId) + ViewAppearanceHelper.applyTheme(this) + setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + modContext.feature(StealthMode::class).setStealth( + conversationId, + isChecked + ) + } + } + + if (friendFeedMenuOptions["anti_auto_save"] == true) { + createToggleFeature(viewConsumer, + "friend_menu_option.anti_auto_save", + { context.feature(AntiAutoSave::class).isConversationIgnored(conversationId) }, + { context.feature(AntiAutoSave::class).setConversationIgnored(conversationId, it) } + ) + } + + run { + val userId = context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId ?: return@run + if (friendFeedMenuOptions["auto_download_blacklist"] == true) { + createToggleFeature(viewConsumer, + "friend_menu_option.auto_download_blacklist", + { context.feature(AntiAutoDownload::class).isUserIgnored(userId) }, + { context.feature(AntiAutoDownload::class).setUserIgnored(userId, it) } + ) + } + } + + if (friendFeedMenuOptions["stealth_mode"] == true) { + viewConsumer(stealthSwitch) + } + if (friendFeedMenuOptions["conversation_info"] == true) { + viewConsumer(previewButton) + } + return + } + + val menuButtonBar = LinearLayout(viewModel.context).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + + fun createActionButton(icon: String, isDisabled: Boolean? = null, onClick: (Boolean) -> Unit) { + //FIXME: hardcoded values + menuButtonBar.addView(LinearLayout(viewModel.context).apply { + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f) + gravity = Gravity.CENTER + isClickable = false + + var isLineThrough = isDisabled ?: false + FriendActionButton.new(viewModel.context).apply { + fun setLineThrough(value: Boolean) { + setIconDrawable(createEmojiDrawable(icon, 60, 60, 50f, if (isDisabled == null) false else value)) + } + setLineThrough(isLineThrough) + (instanceNonNull() as View).apply { + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 40, 0, 40) + } + setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_UP) { + isLineThrough = !isLineThrough + onClick(isLineThrough) + setLineThrough(isLineThrough) + } + false + } + } + + }.also { addView(it.instanceNonNull() as View) } + + }) + } + + if (friendFeedMenuOptions["auto_download_blacklist"] == true) { + run { + val userId = + context.database.getFriendFeedInfoByConversationId(conversationId)?.friendUserId + ?: return@run + createActionButton( + "\u2B07\uFE0F", + isDisabled = !context.feature(AntiAutoDownload::class).isUserIgnored(userId) + ) { + context.feature(AntiAutoDownload::class).setUserIgnored(userId, !it) + } + } + } + + if (friendFeedMenuOptions["anti_auto_save"] == true) { + //diskette + createActionButton("\uD83D\uDCAC", + isDisabled = !context.feature(AntiAutoSave::class) + .isConversationIgnored(conversationId) + ) { + context.feature(AntiAutoSave::class).setConversationIgnored(conversationId, !it) + } + } + + + if (friendFeedMenuOptions["stealth_mode"] == true) { + //eyes + createActionButton( + "\uD83D\uDC7B", + isDisabled = !context.feature(StealthMode::class).isStealth(conversationId) + ) { isChecked -> + context.feature(StealthMode::class).setStealth( + conversationId, + !isChecked + ) + } + } + + if (friendFeedMenuOptions["conversation_info"] == true) { + //user + createActionButton("\uD83D\uDC64") { + showPreview( + targetUser, + conversationId, + viewModel.context + ) + } + } + + viewConsumer(menuButtonBar) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/MenuViewInjector.kt @@ -0,0 +1,134 @@ +package me.rhunk.snapenhance.ui.menu.impl + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import de.robv.android.xposed.XC_MethodHook.Unhook +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import java.lang.reflect.Modifier + +@SuppressLint("DiscouragedApi") +class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + private val friendFeedInfoMenu = FriendFeedInfoMenu() + private val operaContextActionMenu = OperaContextActionMenu() + private val chatActionMenu = ChatActionMenu() + private val settingMenu = SettingsMenu() + + private val newChatString by lazy { + context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME)) + } + + private val fetchConversationHooks = mutableSetOf<Unhook>() + + private fun unhookFetchConversation() { + fetchConversationHooks.let { + it.removeIf { hook -> hook.unhook() ; true} + } + } + + @SuppressLint("ResourceType") + override fun asyncOnActivityCreate() { + friendFeedInfoMenu.context = context + operaContextActionMenu.context = context + chatActionMenu.context = context + settingMenu.context = context + + val messaging = context.feature(Messaging::class) + + val actionSheetItemsContainerLayoutId = context.resources.getIdentifier("action_sheet_items_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) + val actionSheetContainer = context.resources.getIdentifier("action_sheet_container", "id", Constants.SNAPCHAT_PACKAGE_NAME) + val actionMenu = context.resources.getIdentifier("action_menu", "id", Constants.SNAPCHAT_PACKAGE_NAME) + + val addViewMethod = ViewGroup::class.java.getMethod( + "addView", + View::class.java, + Int::class.javaPrimitiveType, + ViewGroup.LayoutParams::class.java + ) + + Hooker.hook(addViewMethod, HookStage.BEFORE) { param -> + val viewGroup: ViewGroup = param.thisObject() + val originalAddView: (View) -> Unit = { + param.invokeOriginal(arrayOf(it, -1, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )) + ) + } + + val childView: View = param.arg(0) + operaContextActionMenu.inject(viewGroup, childView) + + //download in chat snaps and notes from the chat action menu + if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) { + if (viewGroup.parent == null || viewGroup.parent.parent == null) return@hook + chatActionMenu.inject(viewGroup) + return@hook + } + + //TODO: inject in group chat menus + if (viewGroup.id == actionSheetContainer && childView.id == actionMenu) { + val injectedLayout = LinearLayout(childView.context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.BOTTOM + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + addView(childView) + } + + fun injectView() { + val viewList = mutableListOf<View>() + friendFeedInfoMenu.inject(injectedLayout) { view -> + view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 10, 0, 10) + } + viewList.add(view) + } + viewList.reversed().forEach { injectedLayout.addView(it, 0) } + } + + param.setArg(0, injectedLayout) + } + + if (viewGroup is LinearLayout && viewGroup.id == actionSheetItemsContainerLayoutId) { + val itemStringInterface by lazy { + childView.javaClass.declaredFields.filter { + !it.type.isPrimitive && Modifier.isAbstract(it.type.modifiers) + }.map { + runCatching { + it.isAccessible = true + it[childView] + }.getOrNull() + }.firstOrNull() + } + + //the 3 dot button shows a menu which contains the first item as a Plain object + if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=$newChatString")) { + settingMenu.inject(viewGroup, originalAddView) + viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) {} + override fun onViewDetachedFromWindow(v: View) { + context.config.writeConfig() + } + }) + return@hook + } + if (messaging.lastFetchConversationUUID == null || messaging.lastFetchConversationUserUUID == null) return@hook + + //filter by the slot index + if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook + friendFeedInfoMenu.inject(viewGroup, originalAddView) + } + } + } + +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/OperaContextActionMenu.kt @@ -0,0 +1,82 @@ +package me.rhunk.snapenhance.ui.menu.impl + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.ui.menu.AbstractMenu +import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme + +@SuppressLint("DiscouragedApi") +class OperaContextActionMenu : AbstractMenu() { + private val contextCardsScrollView by lazy { + context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) + } + + /* + LinearLayout : + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + */ + private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { + if (viewGroup !is LinearLayout) return false + val children = ArrayList<View>() + for (i in 0 until viewGroup.getChildCount()) + children.add(viewGroup.getChildAt(i)) + return if (children.any { view: View? -> view !is LinearLayout }) + false + else children.map { view: View -> view as LinearLayout } + .any { linearLayout: LinearLayout -> + val viewChildren = ArrayList<View>() + for (i in 0 until linearLayout.childCount) viewChildren.add( + linearLayout.getChildAt( + i + ) + ) + viewChildren.any { viewChild: View -> + viewChild.javaClass.name.endsWith("SnapFontTextView") + } + } + } + + @SuppressLint("SetTextI18n") + fun inject(viewGroup: ViewGroup, childView: View) { + try { + if (viewGroup.parent !is ScrollView) return + val parent = viewGroup.parent as ScrollView + if (parent.id != contextCardsScrollView) return + if (childView !is LinearLayout) return + if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return + + val linearLayout = LinearLayout(childView.getContext()) + linearLayout.orientation = LinearLayout.VERTICAL + linearLayout.gravity = Gravity.CENTER + linearLayout.layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val button = Button(childView.getContext()) + button.text = context.translation.get("opera_context_menu.download") + button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } + applyTheme(button) + linearLayout.addView(button) + (childView as ViewGroup).addView(linearLayout, 0) + } catch (e: Throwable) { + Logger.xposedLog(e) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/menu/impl/SettingsMenu.kt @@ -0,0 +1,242 @@ +package me.rhunk.snapenhance.ui.menu.impl + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.graphics.Color +import android.graphics.Typeface +import android.text.InputType +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.TextView +import me.rhunk.snapenhance.BuildConfig +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.config.impl.ConfigIntegerValue +import me.rhunk.snapenhance.config.impl.ConfigStateListValue +import me.rhunk.snapenhance.config.impl.ConfigStateSelection +import me.rhunk.snapenhance.config.impl.ConfigStateValue +import me.rhunk.snapenhance.config.impl.ConfigStringValue +import me.rhunk.snapenhance.ui.menu.AbstractMenu +import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper + +class SettingsMenu : AbstractMenu() { + @SuppressLint("ClickableViewAccessibility") + private fun createCategoryTitle(key: String): TextView { + val categoryText = TextView(context.androidContext) + categoryText.text = context.translation.get(key) + ViewAppearanceHelper.applyTheme(categoryText) + categoryText.textSize = 20f + categoryText.typeface = categoryText.typeface?.let { Typeface.create(it, Typeface.BOLD) } + categoryText.setOnTouchListener { _, _ -> true } + return categoryText + } + + @SuppressLint("SetTextI18n") + private fun createPropertyView(property: ConfigProperty): View { + val propertyName = context.translation.get(property.nameKey) + val updateButtonText: (TextView, String) -> Unit = { textView, text -> + textView.text = "$propertyName${if (text.isEmpty()) "" else ": $text"}" + } + + val updateLocalizedText: (TextView, String) -> Unit = { textView, value -> + updateButtonText(textView, value.let { + if (it.isEmpty()) { + "(empty)" + } + else { + if (property.disableValueLocalization) { + it + } else { + context.translation.get("option." + property.nameKey + "." + it) + } + } + }) + } + + val textEditor: ((String) -> Unit) -> Unit = { updateValue -> + val builder = AlertDialog.Builder(context.mainActivity!!) + builder.setTitle(propertyName) + + val input = EditText(context.androidContext) + input.inputType = InputType.TYPE_CLASS_TEXT + input.setText(property.valueContainer.value().toString()) + + builder.setView(input) + builder.setPositiveButton("OK") { _, _ -> + updateValue(input.text.toString()) + } + + builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } + builder.show() + } + + val resultView: View = when (property.valueContainer) { + is ConfigStringValue -> { + val textView = TextView(context.androidContext) + updateButtonText(textView, property.valueContainer.let { + if (it.isHidden) it.hiddenValue() + else it.value() + }) + ViewAppearanceHelper.applyTheme(textView) + textView.setOnClickListener { + textEditor { value -> + property.valueContainer.writeFrom(value) + updateButtonText(textView, property.valueContainer.let { + if (it.isHidden) it.hiddenValue() + else it.value() + }) + } + } + textView + } + is ConfigIntegerValue -> { + val button = Button(context.androidContext) + updateButtonText(button, property.valueContainer.value().toString()) + button.setOnClickListener { + textEditor { value -> + runCatching { + property.valueContainer.writeFrom(value) + updateButtonText(button, value) + }.onFailure { + context.shortToast("Invalid value") + } + } + } + ViewAppearanceHelper.applyTheme(button) + button + } + is ConfigStateValue -> { + val switch = Switch(context.androidContext) + switch.text = propertyName + switch.isChecked = property.valueContainer.value() + switch.setOnCheckedChangeListener { _, isChecked -> + property.valueContainer.writeFrom(isChecked.toString()) + } + ViewAppearanceHelper.applyTheme(switch) + switch + } + is ConfigStateSelection -> { + val button = Button(context.androidContext) + updateLocalizedText(button, property.valueContainer.value()) + + button.setOnClickListener {_ -> + val builder = AlertDialog.Builder(context.mainActivity!!) + builder.setTitle(propertyName) + + builder.setSingleChoiceItems( + property.valueContainer.keys().toTypedArray().map { + if (property.disableValueLocalization) it + else context.translation.get("option." + property.nameKey + "." + it) + }.toTypedArray(), + property.valueContainer.keys().indexOf(property.valueContainer.value()) + ) { _, which -> + property.valueContainer.writeFrom(property.valueContainer.keys()[which]) + } + + builder.setPositiveButton("OK") { _, _ -> + updateLocalizedText(button, property.valueContainer.value()) + } + + builder.show() + } + ViewAppearanceHelper.applyTheme(button) + button + } + is ConfigStateListValue -> { + val button = Button(context.androidContext) + updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") + + button.setOnClickListener {_ -> + val builder = AlertDialog.Builder(context.mainActivity!!) + builder.setTitle(propertyName) + + val sortedStates = property.valueContainer.value().toSortedMap() + + builder.setMultiChoiceItems( + sortedStates.toSortedMap().map { + if (property.disableValueLocalization) it.key + else context.translation.get("option." + property.nameKey + "." + it.key) + }.toTypedArray(), + sortedStates.map { it.value }.toBooleanArray() + ) { _, which, isChecked -> + sortedStates.keys.toList()[which].let { key -> + property.valueContainer.setKey(key, isChecked) + } + } + + builder.setPositiveButton("OK") { _, _ -> + updateButtonText(button, "(${property.valueContainer.value().count { it.value }})") + } + + builder.show() + } + ViewAppearanceHelper.applyTheme(button) + button + } + else -> { + TextView(context.androidContext) + } + } + return resultView + } + + private fun newSeparator(thickness: Int, color: Int = Color.BLACK): View { + return LinearLayout(context.mainActivity).apply { + setPadding(0, 0, 0, thickness) + setBackgroundColor(color) + } + } + + @SuppressLint("SetTextI18n") + @Suppress("deprecation") + fun inject(viewModel: View, addView: (View) -> Unit) { + val packageInfo = viewModel.context.packageManager.getPackageInfo(Constants.SNAPCHAT_PACKAGE_NAME, 0) + val versionTextBuilder = StringBuilder() + versionTextBuilder.append("SnapEnhance ").append(BuildConfig.VERSION_NAME) + .append(" by rhunk") + if (BuildConfig.DEBUG) { + versionTextBuilder.append("\n").append("Snapchat ").append(packageInfo.versionName) + .append(" (").append(packageInfo.longVersionCode).append(")") + } + val titleText = TextView(viewModel.context) + titleText.text = versionTextBuilder.toString() + ViewAppearanceHelper.applyTheme(titleText) + titleText.textSize = 18f + titleText.minHeight = 80 * versionTextBuilder.chars().filter { ch: Int -> ch == '\n'.code } + .count().coerceAtLeast(2).toInt() + addView(titleText) + + val actions = context.actionManager.getActions().map { + Pair(it) { + val button = Button(viewModel.context) + button.text = context.translation.get(it.nameKey) + button.setOnClickListener { _ -> + it.run() + } + ViewAppearanceHelper.applyTheme(button) + button + } + } + + context.config.entries().groupBy { + it.key.category + }.forEach { (category, value) -> + addView(createCategoryTitle(category.key)) + value.filter { it.key.shouldAppearInSettings }.forEach { (property, _) -> + addView(createPropertyView(property)) + actions.find { pair -> pair.first.dependsOnProperty == property}?.let { pair -> + addView(pair.second()) + } + } + } + + actions.filter { it.first.dependsOnProperty == null }.forEach { + addView(it.second()) + } + + addView(newSeparator(3, Color.parseColor("#f5f5f5"))) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt @@ -1,67 +0,0 @@ -package me.rhunk.snapenhance.util - -import me.rhunk.snapenhance.Constants -import me.rhunk.snapenhance.data.ContentType -import me.rhunk.snapenhance.util.protobuf.ProtoReader -import java.io.InputStream -import java.util.Base64 -import javax.crypto.Cipher -import javax.crypto.CipherInputStream -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -object EncryptionUtils { - fun decryptInputStreamFromArroyo( - inputStream: InputStream, - contentType: ContentType, - messageProto: ProtoReader - ): InputStream { - var resultInputStream = inputStream - val encryptionProtoPath: IntArray = when (contentType) { - ContentType.NOTE -> Constants.ARROYO_NOTE_ENCRYPTION_PROTO_PATH - ContentType.SNAP -> Constants.ARROYO_SNAP_ENCRYPTION_PROTO_PATH - ContentType.EXTERNAL_MEDIA -> Constants.ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH - else -> throw IllegalArgumentException("Invalid content type: $contentType") - } - - //decrypt the content if needed - messageProto.readPath(*encryptionProtoPath)?.let { - val encryptionProtoIndex: Int = if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2)) { - Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2 - } else if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) { - Constants.ARROYO_ENCRYPTION_PROTO_INDEX - } else { - return resultInputStream - } - resultInputStream = decryptInputStream( - resultInputStream, - encryptionProtoIndex == Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2, - it, - encryptionProtoIndex - ) - } - return resultInputStream - } - - fun decryptInputStream( - inputStream: InputStream, - base64Encryption: Boolean, - mediaInfoProto: ProtoReader, - encryptionProtoIndex: Int - ): InputStream { - val mediaEncryption = mediaInfoProto.readPath(encryptionProtoIndex)!! - var key: ByteArray = mediaEncryption.getByteArray(1)!! - var iv: ByteArray = mediaEncryption.getByteArray(2)!! - - //audio note and external medias have their key and iv encoded in base64 - if (base64Encryption) { - val decoder = Base64.getMimeDecoder() - key = decoder.decode(key) - iv = decoder.decode(iv) - } - - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) - return CipherInputStream(inputStream, cipher) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/MediaDownloaderHelper.kt @@ -1,117 +0,0 @@ -package me.rhunk.snapenhance.util - -import com.arthenica.ffmpegkit.FFmpegKit -import me.rhunk.snapenhance.Logger -import me.rhunk.snapenhance.data.FileType -import me.rhunk.snapenhance.util.download.RemoteMediaResolver -import java.io.ByteArrayInputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.io.InputStream -import java.util.zip.ZipInputStream - -enum class MediaType { - ORIGINAL, OVERLAY -} -object MediaDownloaderHelper { - fun downloadMediaFromReference(mediaReference: ByteArray, mergeOverlay: Boolean, isPreviewMode: Boolean, decryptionCallback: (InputStream) -> InputStream): Map<MediaType, ByteArray> { - val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") - val content = decryptionCallback(inputStream).readBytes() - val fileType = FileType.fromByteArray(content) - val isZipFile = fileType == FileType.ZIP - - //videos with overlay are packed in a zip file - //there are 2 files in the zip file, the video (webm) and the overlay (png) - if (isZipFile) { - var videoData: ByteArray? = null - var overlayData: ByteArray? = null - val zipInputStream = ZipInputStream(ByteArrayInputStream(content)) - while (zipInputStream.nextEntry != null) { - val zipEntryData: ByteArray = zipInputStream.readBytes() - val entryFileType = FileType.fromByteArray(zipEntryData) - if (entryFileType.isVideo) { - videoData = zipEntryData - } else if (entryFileType.isImage) { - overlayData = zipEntryData - } - } - videoData ?: throw FileNotFoundException("Unable to find video file in zip file") - overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file") - if (mergeOverlay) { - val mergedVideo = mergeOverlay(videoData, overlayData, isPreviewMode) - return mapOf(MediaType.ORIGINAL to mergedVideo) - } - return mapOf(MediaType.ORIGINAL to videoData, MediaType.OVERLAY to overlayData) - } - - return mapOf(MediaType.ORIGINAL to content) - } - - fun downloadDashChapter(playlistXmlData: String, startTime: Long, duration: Long?): ByteArray { - val outputFile = File.createTempFile("output", ".mp4") - val playlistFile = File.createTempFile("playlist", ".mpd").also { - with(FileOutputStream(it)) { - write(playlistXmlData.toByteArray(Charsets.UTF_8)) - close() - } - } - - val ffmpegSession = FFmpegKit.execute( - "-y -i " + - playlistFile.absolutePath + - " -ss '${startTime}ms'" + - (if (duration != null) " -t '${duration}ms'" else "") + - " -c:v libx264 -threads 6 -q:v 13 " + outputFile.absolutePath - ) - - playlistFile.delete() - if (!ffmpegSession.returnCode.isValueSuccess) { - throw Exception(ffmpegSession.output) - } - val outputData = FileInputStream(outputFile).readBytes() - outputFile.delete() - return outputData - } - - fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray { - val originalFileType = FileType.fromByteArray(original) - val overlayFileType = FileType.fromByteArray(overlay) - //merge files - val mergedFile = File.createTempFile("merged", "." + originalFileType.fileExtension) - val tempVideoFile = File.createTempFile("original", "." + originalFileType.fileExtension).also { - with(FileOutputStream(it)) { - write(original) - close() - } - } - val tempOverlayFile = File.createTempFile("overlay", "." + overlayFileType.fileExtension).also { - with(FileOutputStream(it)) { - write(overlay) - close() - } - } - - //TODO: improve ffmpeg speed - val fFmpegSession = FFmpegKit.execute( - "-y -i " + - tempVideoFile.absolutePath + - " -i " + - tempOverlayFile.absolutePath + - " -filter_complex \"[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)\" -c:v libx264 -q:v 13 -c:a copy " + - " -threads 6 ${(if (isPreviewMode) "-frames:v 1" else "")} " + - mergedFile.absolutePath - ) - tempVideoFile.delete() - tempOverlayFile.delete() - if (fFmpegSession.returnCode.value != 0) { - mergedFile.delete() - Logger.xposedLog(fFmpegSession.output) - throw IllegalStateException("Failed to merge video and overlay. See logs for more details.") - } - val mergedFileData: ByteArray = FileInputStream(mergedFile).readBytes() - mergedFile.delete() - return mergedFileData - } -}- \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt @@ -1,41 +0,0 @@ -package me.rhunk.snapenhance.util - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.media.MediaDataSource -import android.media.MediaMetadataRetriever - -object PreviewUtils { - fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { - if (!isVideo) { - return BitmapFactory.decodeByteArray(data, 0, data.size) - } - val retriever = MediaMetadataRetriever() - retriever.setDataSource(object : MediaDataSource() { - override fun readAt( - position: Long, - buffer: ByteArray, - offset: Int, - size: Int - ): Int { - var newSize = size - val length = data.size - if (position >= length) { - return -1 - } - if (position + newSize > length) { - newSize = length - position.toInt() - } - System.arraycopy(data, position.toInt(), buffer, offset, newSize) - return newSize - } - - override fun getSize(): Long { - return data.size.toLong() - } - - override fun close() {} - }) - return retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - } -} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/SQLiteDatabaseHelper.kt @@ -0,0 +1,31 @@ +package me.rhunk.snapenhance.util + +import android.annotation.SuppressLint +import android.database.sqlite.SQLiteDatabase +import me.rhunk.snapenhance.Logger + +object SQLiteDatabaseHelper { + @SuppressLint("Range") + fun createTablesFromSchema(sqLiteDatabase: SQLiteDatabase, databaseSchema: Map<String, List<String>>) { + databaseSchema.forEach { (tableName, columns) -> + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + + val cursor = sqLiteDatabase.rawQuery("PRAGMA table_info($tableName)", null) + val existingColumns = mutableListOf<String>() + while (cursor.moveToNext()) { + existingColumns.add(cursor.getString(cursor.getColumnIndex("name")) + " " + cursor.getString(cursor.getColumnIndex("type"))) + } + cursor.close() + + val newColumns = columns.filter { + existingColumns.none { existingColumn -> it.startsWith(existingColumn) } + } + + if (newColumns.isEmpty()) return@forEach + + Logger.log("Schema for table $tableName has changed") + sqLiteDatabase.execSQL("DROP TABLE $tableName") + sqLiteDatabase.execSQL("CREATE TABLE IF NOT EXISTS $tableName (${columns.joinToString(", ")})") + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt @@ -2,9 +2,8 @@ package me.rhunk.snapenhance.util.download import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger.debug -import me.rhunk.snapenhance.ModContext import java.io.BufferedReader -import java.io.File +import java.io.InputStream import java.io.InputStreamReader import java.io.PrintWriter import java.net.ServerSocket @@ -13,38 +12,23 @@ import java.util.Locale import java.util.StringTokenizer import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ThreadLocalRandom -import java.util.function.Consumer -class DownloadServer( - private val context: ModContext -) { +class DownloadServer { private val port = ThreadLocalRandom.current().nextInt(10000, 65535) - private val cachedData = ConcurrentHashMap<String, ByteArray>() + private val cachedData = ConcurrentHashMap<String, InputStream>() private var serverSocket: ServerSocket? = null - fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) { - val httpKey = java.lang.Long.toHexString(System.nanoTime()) - ensureServerStarted { - putDownloadableContent(httpKey, content) - val url = "http://127.0.0.1:$port/$httpKey" - context.executeAsync { - val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath) - callback.accept(result) - } - } - } - - private fun ensureServerStarted(callback: Runnable) { + fun ensureServerStarted(callback: DownloadServer.() -> Unit) { if (serverSocket != null && !serverSocket!!.isClosed) { - callback.run() + callback(this) return } Thread { try { debug("started web server on 127.0.0.1:$port") serverSocket = ServerSocket(port) - callback.run() + callback(this) while (!serverSocket!!.isClosed) { try { val socket = serverSocket!!.accept() @@ -59,8 +43,10 @@ class DownloadServer( }.start() } - fun putDownloadableContent(key: String, data: ByteArray) { - cachedData[key] = data + fun putDownloadableContent(inputStream: InputStream): String { + val key = System.nanoTime().toString(16) + cachedData[key] = inputStream + return "http://127.0.0.1:$port/$key" } private fun handleRequest(socket: Socket) { @@ -68,49 +54,58 @@ class DownloadServer( val outputStream = socket.getOutputStream() val writer = PrintWriter(outputStream) val line = reader.readLine() ?: return - val close = Runnable { - try { + fun close() { + runCatching { reader.close() writer.close() outputStream.close() socket.close() - } catch (e: Throwable) { - Logger.xposedLog(e) + }.onFailure { + Logger.error("failed to close socket", it) } } val parse = StringTokenizer(line) val method = parse.nextToken().uppercase(Locale.getDefault()) var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) if (method != "GET") { - writer.println("HTTP/1.1 501 Not Implemented") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + 0) - writer.println() - writer.flush() - close.run() + with(writer) { + println("HTTP/1.1 501 Not Implemented") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() + } + close() return } if (fileRequested.startsWith("/")) { fileRequested = fileRequested.substring(1) } if (!cachedData.containsKey(fileRequested)) { - writer.println("HTTP/1.1 404 Not Found") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + 0) - writer.println() - writer.flush() - close.run() + with(writer) { + println("HTTP/1.1 404 Not Found") + println("Content-type: " + "application/octet-stream") + println("Content-length: " + 0) + println() + flush() + } + close() return } - val data = cachedData[fileRequested]!! - writer.println("HTTP/1.1 200 OK") - writer.println("Content-type: " + "application/octet-stream") - writer.println("Content-length: " + data.size) - writer.println() - writer.flush() - outputStream.write(data, 0, data.size) + val requestedData = cachedData[fileRequested]!! + with(writer) { + println("HTTP/1.1 200 OK") + println("Content-type: " + "application/octet-stream") + println() + flush() + } + val buffer = ByteArray(1024) + var bytesRead: Int + while (requestedData.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + } outputStream.flush() - close.run() cachedData.remove(fileRequested) + close() } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/BitmojiSelfie.kt @@ -0,0 +1,15 @@ +package me.rhunk.snapenhance.util.snap + +object BitmojiSelfie { + enum class BitmojiSelfieType { + STANDARD, + THREE_D + } + + fun getBitmojiSelfie(selfieId: String, avatarId: String, type: BitmojiSelfieType): String { + return when (type) { + BitmojiSelfieType.STANDARD -> "https://sdk.bitmoji.com/render/panel/$selfieId-$avatarId-v1.webp?transparent=1" + BitmojiSelfieType.THREE_D -> "https://images.bitmoji.com/3d/render/$selfieId-$avatarId-v1.webp?trim=circle" + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt @@ -0,0 +1,53 @@ +package me.rhunk.snapenhance.util.snap + +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.util.protobuf.ProtoReader +import java.io.InputStream +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object EncryptionHelper { + fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? { + val messageMediaInfo = + MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) + + return messageMediaInfo?.let { mediaEncryption -> + val encryptionProtoIndex: Int = if (mediaEncryption.exists(Constants.ENCRYPTION_PROTO_INDEX_V2)) { + Constants.ENCRYPTION_PROTO_INDEX_V2 + } else { + Constants.ENCRYPTION_PROTO_INDEX + } + + val encryptionProto = mediaEncryption.readPath(encryptionProtoIndex) ?: return null + var key: ByteArray = encryptionProto.getByteArray(1)!! + var iv: ByteArray = encryptionProto.getByteArray(2)!! + + if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) { + val decoder = Base64.getMimeDecoder() + key = decoder.decode(key) + iv = decoder.decode(iv) + } + + return Pair(key, iv) + } + } + + fun decryptInputStream( + inputStream: InputStream, + contentType: ContentType, + messageProto: ProtoReader, + isArroyo: Boolean + ): InputStream { + val encryptionKeys = getEncryptionKeys(contentType, messageProto, isArroyo) ?: throw Exception("Failed to get encryption keys") + + Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKeys.first, "AES"), IvParameterSpec(encryptionKeys.second)) + }.let { cipher -> + return CipherInputStream(inputStream, cipher) + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -0,0 +1,100 @@ +package me.rhunk.snapenhance.util.snap + +import com.arthenica.ffmpegkit.FFmpegKit +import com.arthenica.ffmpegkit.FFmpegSession +import kotlinx.coroutines.suspendCancellableCoroutine +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.util.download.RemoteMediaResolver +import me.rhunk.snapenhance.util.protobuf.ProtoReader +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.util.concurrent.Executors +import java.util.zip.ZipInputStream + +enum class MediaType { + ORIGINAL, OVERLAY +} + +object MediaDownloaderHelper { + fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { + val messageContainerPath = if (isArroyo) protoReader.readPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader + val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1) + + return when (contentType) { + ContentType.NOTE -> messageContainerPath.readPath(*mediaContainerPath) + ContentType.SNAP -> messageContainerPath.readPath(*(intArrayOf(11) + mediaContainerPath)) + ContentType.EXTERNAL_MEDIA -> messageContainerPath.readPath(*(intArrayOf(3, 3) + mediaContainerPath)) + else -> throw IllegalArgumentException("Invalid content type: $contentType") + } + } + + fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<MediaType, ByteArray> { + val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") + val content = decryptionCallback(inputStream).readBytes() + val fileType = FileType.fromByteArray(content) + val isZipFile = fileType == FileType.ZIP + + //videos with overlay are packed in a zip file + //there are 2 files in the zip file, the video (webm) and the overlay (png) + if (isZipFile) { + var videoData: ByteArray? = null + var overlayData: ByteArray? = null + val zipInputStream = ZipInputStream(ByteArrayInputStream(content)) + while (zipInputStream.nextEntry != null) { + val zipEntryData: ByteArray = zipInputStream.readBytes() + val entryFileType = FileType.fromByteArray(zipEntryData) + if (entryFileType.isVideo) { + videoData = zipEntryData + } else if (entryFileType.isImage) { + overlayData = zipEntryData + } + } + videoData ?: throw FileNotFoundException("Unable to find video file in zip file") + overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file") + return mapOf(MediaType.ORIGINAL to videoData, MediaType.OVERLAY to overlayData) + } + + return mapOf(MediaType.ORIGINAL to content) + } + + + private suspend fun runFFmpegAsync(vararg args: String) = suspendCancellableCoroutine<FFmpegSession> { + FFmpegKit.executeAsync(args.joinToString(" "), { session -> + it.resumeWith( + if (session.returnCode.isValueSuccess) { + Result.success(session) + } else { + Result.failure(Exception(session.output)) + } + ) + }, + Executors.newSingleThreadExecutor()) + } + + suspend fun downloadDashChapterFile( + dashPlaylist: File, + output: File, + startTime: Long, + duration: Long?) { + runFFmpegAsync( + "-y", "-i", dashPlaylist.absolutePath, "-ss", "'${startTime}ms'", *(if (duration != null) arrayOf("-t", "'${duration}ms'") else arrayOf()), + "-c:v", "libx264", "-threads", "6", "-q:v", "13", output.absolutePath + ) + } + + suspend fun mergeOverlayFile( + media: File, + overlay: File, + output: File + ) { + runFFmpegAsync( + "-y", "-i", media.absolutePath, "-i", overlay.absolutePath, + "-filter_complex", "\"[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)\"", + "-c:v", "libx264", "-b:v", "5M", "-c:a", "copy", "-threads", "6", output.absolutePath + ) + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/PreviewUtils.kt @@ -0,0 +1,92 @@ +package me.rhunk.snapenhance.util.snap + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.media.MediaDataSource +import android.media.MediaMetadataRetriever +import me.rhunk.snapenhance.data.FileType +import java.io.File +import kotlin.math.roundToInt + +object PreviewUtils { + fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? { + if (!isVideo) { + return BitmapFactory.decodeByteArray(data, 0, data.size) + } + return MediaMetadataRetriever().apply { + setDataSource(object : MediaDataSource() { + override fun readAt( + position: Long, + buffer: ByteArray, + offset: Int, + size: Int + ): Int { + var newSize = size + val length = data.size + if (position >= length) { + return -1 + } + if (position + newSize > length) { + newSize = length - position.toInt() + } + System.arraycopy(data, position.toInt(), buffer, offset, newSize) + return newSize + } + + override fun getSize(): Long { + return data.size.toLong() + } + override fun close() {} + }) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + } + + fun createPreviewFromFile(file: File, scaleFactor: Float): Bitmap? { + return if (FileType.fromFile(file).isVideo) { + MediaMetadataRetriever().apply { + setDataSource(file.absolutePath) + }.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)?.let { + resizeBitmap(it, (it.width * scaleFactor).toInt(), (it.height * scaleFactor).toInt()) + } + } else { + BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { + inSampleSize = (1 / scaleFactor).roundToInt() + }) + } + } + + private fun resizeBitmap(bitmap: Bitmap, outWidth: Int, outHeight: Int): Bitmap? { + val scaleWidth = outWidth.toFloat() / bitmap.width + val scaleHeight = outHeight.toFloat() / bitmap.height + val matrix = Matrix() + matrix.postScale(scaleWidth, scaleHeight) + val resizedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, false) + bitmap.recycle() + return resizedBitmap + } + + fun mergeBitmapOverlay(originalMedia: Bitmap, overlayLayer: Bitmap): Bitmap { + val biggestBitmap = if (originalMedia.width * originalMedia.height > overlayLayer.width * overlayLayer.height) originalMedia else overlayLayer + val smallestBitmap = if (biggestBitmap == originalMedia) overlayLayer else originalMedia + + val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config) + + with(Canvas(mergedBitmap)) { + val scaleMatrix = Matrix().apply { + postScale(biggestBitmap.width.toFloat() / smallestBitmap.width.toFloat(), biggestBitmap.height.toFloat() / smallestBitmap.height.toFloat()) + } + + if (biggestBitmap == originalMedia) { + drawBitmap(originalMedia, 0f, 0f, null) + drawBitmap(overlayLayer, scaleMatrix, null) + } else { + drawBitmap(originalMedia, scaleMatrix, null) + drawBitmap(overlayLayer, 0f, 0f, null) + } + } + + return mergedBitmap + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt @@ -1,2 +0,0 @@ -package me.rhunk.snapenhance.util.snap - diff --git a/app/src/main/res/drawable/action_button_cancel.xml b/app/src/main/res/drawable/action_button_cancel.xml @@ -0,0 +1,4 @@ +<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="@color/errorColor" /> + <corners android:radius="10000dp" /> +</shape>+ \ No newline at end of file diff --git a/app/src/main/res/drawable/action_button_success.xml b/app/src/main/res/drawable/action_button_success.xml @@ -0,0 +1,8 @@ +<shape android:shape="rectangle" + xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="@color/secondaryBackground" /> + <corners android:radius="10000dp" /> + <stroke + android:width="2dp" + android:color="@color/successColor" /> +</shape>+ \ No newline at end of file diff --git a/app/src/main/res/drawable/download_manager_item_background.xml b/app/src/main/res/drawable/download_manager_item_background.xml @@ -0,0 +1,5 @@ +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid android:color="@color/secondaryBackground" /> + <corners android:radius="@dimen/download_manager_item_preview_radius" /> +</shape>+ \ No newline at end of file diff --git a/app/src/main/res/font/avenir_next_medium.ttf b/app/src/main/res/font/avenir_next_medium.ttf diff --git a/app/src/main/res/layout/download_manager_activity.xml b/app/src/main/res/layout/download_manager_activity.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/download_manager_activity" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@color/primaryBackground" + android:orientation="vertical"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="50dp" + android:background="@color/secondaryBackground" + android:gravity="center_vertical" + android:orientation="horizontal" + android:layoutDirection="rtl"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="right" + android:padding="10dp" + tools:ignore="RtlHardcoded"> + <Button + android:id="@+id/remove_all_button" + android:layout_width="wrap_content" + android:layout_height="35dp" + android:background="@drawable/action_button_cancel" + android:padding="5dp" + android:text="Remove All" + android:textColor="@color/darkText" /> + </RelativeLayout> + + </LinearLayout> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/download_list" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + </androidx.recyclerview.widget.RecyclerView> + + <TextView + android:id="@+id/no_download_title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:foregroundGravity="clip_horizontal" + android:gravity="center" + android:paddingVertical="40dp" + android:text="No downloads" + android:textColor="@color/primaryText" /> + + </RelativeLayout> + +</LinearLayout>+ \ No newline at end of file diff --git a/app/src/main/res/layout/download_manager_item.xml b/app/src/main/res/layout/download_manager_item.xml @@ -0,0 +1,70 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="100dp" + android:layout_margin="5dp" + android:layout_marginBottom="10dp" + android:background="@drawable/download_manager_item_background" + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="5dp"> + + <RelativeLayout + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1"> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="@drawable/download_manager_item_background" + android:orientation="horizontal" + android:padding="5dp" + tools:ignore="UselessParent"> + <ImageView + android:id="@+id/bitmoji_icon" + android:layout_width="45dp" + android:layout_height="45dp" + android:layout_margin="5dp" + tools:ignore="ContentDescription" /> + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:padding="5dp" + android:orientation="vertical"> + <TextView + android:id="@+id/item_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/primaryText" + android:textSize="17sp" + android:textStyle="bold" /> + + <TextView + android:id="@+id/item_subtitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="14sp" + android:textColor="@color/secondaryText" /> + </LinearLayout> + </LinearLayout> + </RelativeLayout> + + + <TextView + android:id="@+id/item_status" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="10dp" + android:textColor="@color/primaryText" /> + + <Button + android:id="@+id/item_action_button" + android:layout_width="wrap_content" + android:layout_height="35dp" + android:background="@drawable/action_button_cancel" + android:padding="5dp" + android:text="Cancel" + android:textColor="@color/darkText" + tools:ignore="ButtonOrder,HardcodedText" /> +</LinearLayout>+ \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="actionBarColor">#0B0B0B</color> + <color name="primaryBackground">#121212</color> + <color name="secondaryBackground">#1E1E1E</color> + <color name="tertiaryBackground">#2B2B2B</color> + <color name="darkText">#2B2B2B</color> + <color name="primaryText">#DEDEDE</color> + <color name="secondaryText">#999999</color> + <color name="errorColor">#DF4C5C</color> + <color name="successColor">#4FABF8</color> +</resources>+ \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <dimen name="download_manager_item_preview_radius">10dp</dimen> +</resources>+ \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<resources> + <style name="AppTheme"> + <item name="android:fontFamily">@font/avenir_next_medium</item> + </style> +</resources>+ \ No newline at end of file