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
This commit is contained in:
rhunk 2023-06-17 11:40:36 +02:00 committed by GitHub
parent fca2f8a53d
commit 2ee64c40ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1915 additions and 599 deletions

View File

@ -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'

View File

@ -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>

View File

@ -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"
}

View File

@ -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

View File

@ -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() {

View File

@ -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) {

View File

@ -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)
})
data = Bundle()
bridgeMessage.write(data)
messenger.send(this)
}
}
lock.withLock {
condition.awaitUninterruptibly()
}
return response.get() as T
}
override fun createAndReadFile(

View File

@ -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)

View File

@ -31,7 +31,7 @@ class MessageSender(
}.toByteArray()
}
val audioNoteProto: (Int) -> ByteArray = { duration ->
val audioNoteProto: (Long) -> ByteArray = { duration ->
ProtoWriter().apply {
write(6, 1) {
write(1) {

View File

@ -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
))
}
}

View File

@ -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()
}
}

View File

@ -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}")
}
}
}
}

View File

@ -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) }
}
}

View File

@ -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))
}

View File

@ -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
}
}

View File

@ -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}")
}
}
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
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)
}
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus
package me.rhunk.snapenhance.ui.map
import android.annotation.SuppressLint
import android.app.Activity
@ -18,7 +18,6 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Overlay
//TODO: Implement correctly
class MapActivity : Activity() {
private lateinit var mapView: MapView

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus
package me.rhunk.snapenhance.ui.menu
import me.rhunk.snapenhance.ModContext

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus
package me.rhunk.snapenhance.ui.menu
import android.annotation.SuppressLint
import android.content.res.ColorStateList

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.os.SystemClock
@ -11,8 +11,9 @@ 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
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() {
@ -84,7 +85,7 @@ class ChatActionMenu : AbstractMenu() {
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)
context.feature(MessageLogger::class).deleteMessage(openedConversationUUID.toString(), lastFocusedMessageId)
}
}
}

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.app.AlertDialog
@ -32,8 +32,9 @@ 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 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
@ -59,9 +60,11 @@ class FriendFeedInfoMenu : AbstractMenu() {
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"
BitmojiSelfie.getBitmojiSelfie(
profile.bitmojiSelfieId.toString(),
profile.bitmojiAvatarId.toString(),
BitmojiSelfie.BitmojiSelfieType.THREE_D
)
)
}
} catch (e: Throwable) {
@ -72,7 +75,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
val builder = AlertDialog.Builder(context.mainActivity)
builder.setIcon(finalIcon)
builder.setTitle(profile.displayName)
builder.setTitle(profile.displayName ?: profile.username)
val birthday = Calendar.getInstance()
birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.view.Gravity
@ -6,15 +6,12 @@ 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.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
@ -30,10 +27,12 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
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
private val fetchConversationHooks = mutableSetOf<Unhook>()
private fun unhookFetchConversation() {
fetchConversationHooks.let {
it.removeIf { hook -> hook.unhook() ; true}
}
}
@SuppressLint("ResourceType")
@ -77,8 +76,8 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
return@hook
}
//inject in group chat menus
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchConversationUserUUID == null) {
//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
@ -86,19 +85,15 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
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)
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.reversed().forEach { injectedLayout.addView(it, 0) }
viewList.add(view)
}
viewList.reversed().forEach { injectedLayout.addView(it, 0) }
}
param.setArg(0, injectedLayout)
@ -127,20 +122,12 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
})
return@hook
}
if (messaging.lastFetchConversationUserUUID == null) 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)
viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}
override fun onViewDetachedFromWindow(v: View) {
messaging.lastFetchConversationUserUUID = null
}
})
}
}
}

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.view.Gravity
@ -10,8 +10,8 @@ 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
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme
@SuppressLint("DiscouragedApi")
class OperaContextActionMenu : AbstractMenu() {

View File

@ -1,4 +1,4 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
package me.rhunk.snapenhance.ui.menu.impl
import android.annotation.SuppressLint
import android.app.AlertDialog
@ -19,8 +19,8 @@ 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
import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
class SettingsMenu : AbstractMenu() {
@SuppressLint("ClickableViewAccessibility")

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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(", ")})")
}
}
}

View File

@ -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()
}
}

View File

@ -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"
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}

View File

@ -1,2 +0,0 @@
package me.rhunk.snapenhance.util.snap

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="download_manager_item_preview_radius">10dp</dimen>
</resources>

View File

@ -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>