diff --git a/app/build.gradle b/app/build.gradle index 4cce9322..07b65f9f 100644 --- 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 index ddd607ea..7473815a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + @@ -28,21 +30,27 @@ + android:exported="true" + tools:ignore="ExportedService"> + + + + + + + android:name=".ui.download.DownloadManagerActivity" + android:exported="true"> diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt index 2799bb9b..577e0d8c 100644 --- 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 index d2a15b63..d7c4b067 100644 --- 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 index 5a89fdaf..9ba340ca 100644 --- 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 index 70cb34c2..601912dd 100644 --- 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 index b0b9e467..f395ceac 100644 --- 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? = null - ): T { - val response = AtomicReference() - 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 { 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) + }) + data = Bundle() + bridgeMessage.write(data) + messenger.send(this) + } } - - lock.withLock { - condition.awaitUninterruptibly() - } - 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 index 0b8c506f..bfdb8125 100644 --- 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() @@ -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 index 40614610..811bd336 100644 --- 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 new file mode 100644 index 00000000..89e6f03c --- /dev/null +++ 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() + + 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 new file mode 100644 index 00000000..0bf37090 --- /dev/null +++ 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() + + @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 { + 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 { + val cursor = taskDatabase.rawQuery("SELECT * FROM tasks WHERE id < ? ORDER BY id DESC LIMIT ?", arrayOf(from.toString(), amount.toString())) + val result = sortedMapOf() + + 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 new file mode 100644 index 00000000..1b649432 --- /dev/null +++ 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 { + val files = mutableListOf() + 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() + val downloadedMedias = mutableMapOf() + + 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, 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 new file mode 100644 index 00000000..a14ddfde --- /dev/null +++ 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, + private val inputTypes: Array, + private val mediaEncryption: Map = emptyMap(), + private val flags: Int = 0, + private val dashOptions: Map? = 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 { + 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 new file mode 100644 index 00000000..7797363d --- /dev/null +++ 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.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 new file mode 100644 index 00000000..fb1b1d85 --- /dev/null +++ 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 new file mode 100644 index 00000000..666fa1f6 --- /dev/null +++ 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 new file mode 100644 index 00000000..f20862ef --- /dev/null +++ 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 index fd0f4b9f..606f6065 100644 --- 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>(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 = 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 index 43326d8a..8329c3aa 100644 --- 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? = 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() - 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, 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 { 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) { + 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) + val messageReader = ProtoReader(messageContent) + val urlProto: ByteArray = if (isArroyoMessage) { + messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!! + } else { + deletedMediaReference!! + } - 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) - } + 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 index 385c743b..43330b68 100644 --- 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 index ff48820b..61002faa 100644 --- 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 index 62f79b18..5851b41f 100644 --- 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 index 2ba9cd6f..82041feb 100644 --- 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 messageReader = ProtoReader(contentData) + val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) { + EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false) + } - 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") + 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 bitmapPreview = PreviewUtils.createPreview(downloadedMedia, mediaType.name.contains("VIDEO"))!! val notificationBuilder = XposedHelpers.newInstance( Notification.Builder::class.java, context.androidContext, 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 index eea94a2a..40818654 100644 --- 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 new file mode 100644 index 00000000..2ac67382 --- /dev/null +++ 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 +): Adapter() { + private val previewJobs = mutableMapOf() + + 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 new file mode 100644 index 00000000..598774cf --- /dev/null +++ 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() + + private val preferences by lazy { + getSharedPreferences("settings", Context.MODE_PRIVATE) + } + + private fun updateNoDownloadText() { + findViewById(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(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