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 { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21' 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') compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
implementation 'com.google.code.gson:gson:2.10.1' 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" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@ -28,21 +30,27 @@
<service <service
android:name=".bridge.service.BridgeService" android:name=".bridge.service.BridgeService"
android:exported="true"> android:exported="true"
tools:ignore="ExportedService">
</service> </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 <activity
android:theme="@android:style/Theme.NoDisplay" android:name=".ui.download.DownloadManagerActivity"
android:name=".bridge.service.MainActivity" android:exported="true">
android:exported="true"
android:excludeFromRecents="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".features.impl.ui.menus.MapActivity" android:name=".ui.map.MapActivity"
android:exported="true" android:exported="true"
android:excludeFromRecents="true" /> android:excludeFromRecents="true" />
</application> </application>

View File

@ -5,18 +5,13 @@ object Constants {
const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android" const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android"
const val VIEW_INJECTED_CODE = 0x7FFFFF02 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_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4)
val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1) val ARROYO_STRING_CHAT_MESSAGE_PROTO = ARROYO_MEDIA_CONTAINER_PROTO_PATH + intArrayOf(2, 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_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3) val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3)
const val ARROYO_ENCRYPTION_PROTO_INDEX = 19 const val ENCRYPTION_PROTO_INDEX = 19
const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4 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" 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 config = ConfigManager(this)
val actionManager = ActionManager(this) val actionManager = ActionManager(this)
val database = DatabaseAccess(this) val database = DatabaseAccess(this)
val downloadServer = DownloadServer(this) val downloadServer = DownloadServer()
val messageSender = MessageSender(this) val messageSender = MessageSender(this)
val classCache get() = SnapEnhance.classCache val classCache get() = SnapEnhance.classCache
val resources: Resources get() = androidContext.resources 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.BuildConfig
import me.rhunk.snapenhance.action.AbstractAction import me.rhunk.snapenhance.action.AbstractAction
import me.rhunk.snapenhance.config.ConfigProperty 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) { class OpenMap: AbstractAction("action.open_map", dependsOnProperty = ConfigProperty.LOCATION_SPOOF) {
override fun run() { override fun run() {

View File

@ -2,6 +2,7 @@ package me.rhunk.snapenhance.bridge
import android.content.ContentValues import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import java.io.File import java.io.File
class MessageLoggerWrapper( class MessageLoggerWrapper(
@ -12,7 +13,14 @@ class MessageLoggerWrapper(
fun init() { fun init() {
database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE) 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) { fun deleteMessage(conversationId: String, messageId: Long) {

View File

@ -13,6 +13,8 @@ import android.os.HandlerThread
import android.os.IBinder import android.os.IBinder
import android.os.Message import android.os.Message
import android.os.Messenger import android.os.Messenger
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import me.rhunk.snapenhance.BuildConfig import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.bridge.AbstractBridgeClient import me.rhunk.snapenhance.bridge.AbstractBridgeClient
@ -86,29 +88,21 @@ class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection {
messageType: BridgeMessageType, messageType: BridgeMessageType,
bridgeMessage: BridgeMessage, bridgeMessage: BridgeMessage,
resultType: KClass<T>? = null resultType: KClass<T>? = null
): T { ) = runBlocking {
val response = AtomicReference<BridgeMessage>() return@runBlocking suspendCancellableCoroutine<T> { continuation ->
val condition = lock.newCondition()
with(Message.obtain()) { with(Message.obtain()) {
what = messageType.value what = messageType.value
replyTo = Messenger(object : Handler(handlerThread.looper) { replyTo = Messenger(object : Handler(handlerThread.looper) {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
response.set(handleResponseMessage(msg)) if (continuation.isCompleted) return
lock.withLock { continuation.resumeWith(Result.success(handleResponseMessage(msg) as T))
condition.signal()
}
} }
}) })
data = Bundle() data = Bundle()
bridgeMessage.write(data) bridgeMessage.write(data)
messenger.send(this) messenger.send(this)
} }
lock.withLock {
condition.awaitUninterruptibly()
} }
return response.get() as T
} }
override fun createAndReadFile( override fun createAndReadFile(

View File

@ -1,19 +1,23 @@
package me.rhunk.snapenhance.data package me.rhunk.snapenhance.data
import java.io.File
enum class FileType( enum class FileType(
val fileExtension: String? = null, val fileExtension: String? = null,
val mimeType: String,
val isVideo: Boolean = false, val isVideo: Boolean = false,
val isImage: Boolean = false, val isImage: Boolean = false,
val isAudio: Boolean = false val isAudio: Boolean = false
) { ) {
GIF("gif", false, false, false), GIF("gif", "image/gif", false, false, false),
PNG("png", false, true, false), PNG("png", "image/png", false, true, false),
MP4("mp4", true, false, false), MP4("mp4", "video/mp4", true, false, false),
MP3("mp3", false, false, true), MP3("mp3", "audio/mp3",false, false, true),
JPG("jpg", false, true, false), JPG("jpg", "image/jpg",false, true, false),
ZIP("zip", false, false, false), ZIP("zip", "application/zip", false, false, false),
WEBP("webp", false, true, false), WEBP("webp", "image/webp", false, true, false),
UNKNOWN("dat", false, false, false); MPD("mpd", "text/xml", false, false, false),
UNKNOWN("dat", "application/octet-stream", false, false, false);
companion object { companion object {
private val fileSignatures = HashMap<String, FileType>() private val fileSignatures = HashMap<String, FileType>()
@ -40,6 +44,14 @@ enum class FileType(
return result.toString() 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 { fun fromByteArray(array: ByteArray): FileType {
val headerBytes = ByteArray(16) val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16) System.arraycopy(array, 0, headerBytes, 0, 16)

View File

@ -31,7 +31,7 @@ class MessageSender(
}.toByteArray() }.toByteArray()
} }
val audioNoteProto: (Int) -> ByteArray = { duration -> val audioNoteProto: (Long) -> ByteArray = { duration ->
ProtoWriter().apply { ProtoWriter().apply {
write(6, 1) { write(6, 1) {
write(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.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker 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) { class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
lateinit var conversationManager: Any lateinit var conversationManager: Any
var openedConversationUUID: SnapUUID? = null
var lastOpenedConversationUUID: SnapUUID? = null var lastOpenedConversationUUID: SnapUUID? = null
var lastFetchConversationUserUUID: SnapUUID? = null var lastFetchConversationUserUUID: SnapUUID? = null
var lastFetchConversationUUID: SnapUUID? = null var lastFetchConversationUUID: SnapUUID? = null
@ -22,24 +25,22 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
} }
override fun onActivityCreate() { override fun onActivityCreate() {
with(context.classCache.conversationManager) { context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param ->
Hooker.hook(this, "enterConversation", HookStage.BEFORE) { val userIdToConversation = (param.arg<ArrayList<*>>(0))
lastOpenedConversationUUID = SnapUUID(it.arg(0)) .takeIf { it.isNotEmpty() }
?.get(0) ?: return@hook
lastFetchConversationUUID = SnapUUID(userIdToConversation.getObjectField("mConversationId"))
lastFetchConversationUserUUID = SnapUUID(userIdToConversation.getObjectField("mUserId"))
} }
Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param -> with(context.classCache.conversationManager) {
val conversationIds: List<Any> = param.arg(0) Hooker.hook(this, "enterConversation", HookStage.BEFORE) {
if (conversationIds.isNotEmpty()) { openedConversationUUID = SnapUUID(it.arg(0))
lastFetchConversationUserUUID = SnapUUID(conversationIds[0])
}
} }
Hooker.hook(this, "exitConversation", HookStage.BEFORE) { Hooker.hook(this, "exitConversation", HookStage.BEFORE) {
lastOpenedConversationUUID = null openedConversationUUID = null
}
Hooker.hook(this, "fetchConversation", HookStage.BEFORE) {
lastFetchConversationUUID = SnapUUID(it.arg(0))
} }
} }
@ -54,7 +55,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
//get last opened snap for media downloader //get last opened snap for media downloader
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param -> Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param ->
lastOpenedConversationUUID = SnapUUID(param.arg(1)) openedConversationUUID = SnapUUID(param.arg(1))
lastFocusedMessageId = param.arg(2) lastFocusedMessageId = param.arg(2)
} }

View File

@ -3,11 +3,11 @@ package me.rhunk.snapenhance.features.impl.downloader
import android.app.AlertDialog import android.app.AlertDialog
import android.content.DialogInterface import android.content.DialogInterface
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaScannerConnection import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.widget.ImageView import android.widget.ImageView
import com.arthenica.ffmpegkit.FFmpegKit 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.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger.xposedLog import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.config.ConfigProperty 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.dash.SnapPlaylistItem
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap 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.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging 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.HookAdapter
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.EncryptionUtils import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.util.MediaType import me.rhunk.snapenhance.util.snap.MediaType
import me.rhunk.snapenhance.util.PreviewUtils import me.rhunk.snapenhance.util.snap.PreviewUtils
import me.rhunk.snapenhance.util.download.RemoteMediaResolver
import me.rhunk.snapenhance.util.getObjectField import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.protobuf.ProtoReader 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.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Paths import java.nio.file.Paths
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Arrays
import java.util.Locale import java.util.Locale
import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.suspendCoroutine
import javax.crypto.Cipher import kotlin.io.encoding.Base64
import javax.crypto.CipherInputStream import kotlin.io.encoding.ExperimentalEncodingApi
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
import kotlin.io.path.inputStream import kotlin.io.path.inputStream
@OptIn(ExperimentalEncodingApi::class)
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null private var lastSeenMapParams: ParamMap? = null
@ -59,12 +53,38 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
runCatching { FFmpegKit.execute("-version") }.isSuccess 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 { private fun canMergeOverlay(): Boolean {
if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["merge_overlay"] == false) return false if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["merge_overlay"] == false) return false
return isFFmpegPresent 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 hexHash = Integer.toHexString(hash)
val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS) val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)
@ -81,13 +101,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
} }
if (downloadOptions["format_user_folder"] == true) { if (downloadOptions["format_user_folder"] == true) {
finalPath.append(author).append("/") finalPath.append(pathPrefix).append("/")
} }
if (downloadOptions["format_hash"] == true) { if (downloadOptions["format_hash"] == true) {
appendFileName(hexHash) appendFileName(hexHash)
} }
if (downloadOptions["format_username"] == true) { if (downloadOptions["format_username"] == true) {
appendFileName(author) appendFileName(pathPrefix)
} }
if (downloadOptions["format_date_time"] == true) { if (downloadOptions["format_date_time"] == true) {
appendFileName(currentDateTime) appendFileName(currentDateTime)
@ -95,79 +115,9 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
if (finalPath.isEmpty()) finalPath.append(hexHash) 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 * Download the last seen media
*/ */
@ -178,41 +128,46 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
} }
} }
private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) { private fun handleLocalReferences(path: String) = runBlocking {
if (mediaInfoMap.isEmpty()) return Uri.parse(path).let { uri ->
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! if (uri.scheme == "file") {
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { return@let suspendCoroutine<String> { continuation ->
context.shortToast("Downloading split snap") context.downloadServer.ensureServerStarted {
val url = putDownloadableContent(Paths.get(uri.path).inputStream())
continuation.resumeWith(Result.success(url))
} }
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
} }
val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!!
val overlayContent: ByteArray = queryMediaData(overlayMediaInfo)
mediaContent = MediaDownloaderHelper.mergeOverlay(mediaContent, overlayContent, false)
} }
val fileType = FileType.fromByteArray(mediaContent!!) path
downloadMediaContent(mediaContent, hash, author, fileType) }
} }
private fun downloadMediaContent( private fun downloadOperaMedia(clientDownloadManager: ClientDownloadManager, mediaInfoMap: Map<MediaType, MediaInfo>) {
data: ByteArray, if (mediaInfoMap.isEmpty()) return
hash: Int,
messageAuthor: String, val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
fileType: FileType val overlay = mediaInfoMap[MediaType.OVERLAY]
): Boolean {
val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) val overlayReference = overlay?.let { handleLocalReferences(it.uri) }
if (outputFile.exists()) {
context.shortToast("Media already exists") overlay?.let {
return false 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 return
} }
val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!! val author = context.database.getFriendInfo(senderId) ?: return
downloadOperaMedia(mediaInfoMap, author) val authorUsername = author.usernameForSorting!!
downloadOperaMedia(provideClientDownloadManager(authorUsername, authorUsername, "Chat Media", friendInfo = author), mediaInfoMap)
return return
} }
@ -250,9 +206,10 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
storyIdStartIndex, storyIdStartIndex,
playlistGroup.indexOf(",", 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 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( val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace(
"[^\\x00-\\x7F]".toRegex(), "[^\\x00-\\x7F]".toRegex(),
"") "")
downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName") downloadOperaMedia(provideClientDownloadManager("Public-Stories/$userDisplayName", userDisplayName, "Public Story"), mediaInfoMap)
return return
} }
//spotlight //spotlight
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) {
downloadOperaMedia(mediaInfoMap, "Spotlight") downloadOperaMedia(provideClientDownloadManager("Spotlight", mediaDisplayType = "Spotlight", mediaDisplaySource = paramMap["TIME_STAMP"].toString()), mediaInfoMap)
return return
} }
@ -302,24 +259,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
//get the mpd playlist and append the cdn url to baseurl nodes //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 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 clientDownloadManager = provideClientDownloadManager("Pro-Stories/${storyName}", storyName, "Pro Story")
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL") clientDownloadManager.downloadDashMedia(
for (i in 0 until baseUrlNodeList.length) { playlistUrl,
val baseUrlNode = baseUrlNodeList.item(i) snapChapterTimestamp,
val baseUrl = baseUrlNode.textContent duration
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)
}
} }
} }
@ -383,60 +328,103 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
fun onMessageActionMenu(isPreviewMode: Boolean) { fun onMessageActionMenu(isPreviewMode: Boolean) {
//check if the message was focused in a conversation //check if the message was focused in a conversation
val messaging = context.feature(Messaging::class) 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 val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return
//get the message author //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 //check if the messageId
val contentType: ContentType = ContentType.fromId(message.content_type) var 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") if (messageLogger.isMessageRemoved(message.client_message_id.toLong())) {
return 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 && if (contentType != ContentType.NOTE &&
contentType != ContentType.SNAP && contentType != ContentType.SNAP &&
contentType != ContentType.EXTERNAL_MEDIA) { contentType != ContentType.EXTERNAL_MEDIA) {
context.shortToast("Unsupported content type $contentType") context.shortToast("Unsupported content type $contentType")
return return
} }
val messageReader = ProtoReader(message.message_content!!)
val urlProto: ByteArray = messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!!
//download the message content val messageReader = ProtoReader(messageContent)
try { val urlProto: ByteArray = if (isArroyoMessage) {
val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(urlProto, canMergeOverlay(), isPreviewMode) { messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!!
EncryptionUtils.decryptInputStreamFromArroyo(it, contentType, messageReader) } else {
}[MediaType.ORIGINAL] ?: throw Exception("Failed to download media") deletedMediaReference!!
val fileType = FileType.fromByteArray(downloadedMedia) }
if (isPreviewMode) {
runCatching { runCatching {
val bitmap: Bitmap? = PreviewUtils.createPreview(downloadedMedia, fileType.isVideo) 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
}
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) { if (bitmap == null) {
context.shortToast("Failed to create preview") context.shortToast("Failed to create preview")
return return
} }
val builder = AlertDialog.Builder(context.mainActivity)
builder.setTitle("Preview") overlay?.let {
val imageView = ImageView(builder.context) bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
imageView.setImageBitmap(bitmap) }
builder.setView(imageView)
builder.setPositiveButton( with(AlertDialog.Builder(context.mainActivity)) {
"Close" setTitle("Preview")
) { dialog: DialogInterface, _: Int -> dialog.dismiss() } setView(ImageView(context).apply {
context.runOnUiThread { builder.show() } setImageBitmap(bitmap)
})
setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() }
this@MediaDownloader.context.runOnUiThread { show() }
}
}.onFailure { }.onFailure {
context.shortToast("Failed to create preview: $it") context.shortToast("Failed to create preview: $it")
xposedLog(it) xposedLog(it)
} }
return }.onFailure {
} context.longToast("Failed to download " + it.message)
downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), messageAuthor, fileType) xposedLog(it)
} catch (e: Throwable) {
context.longToast("Failed to download " + e.message)
xposedLog(e)
} }
} }
} }

View File

@ -37,6 +37,15 @@ class MessageLogger : Feature("MessageLogger",
context.bridgeClient.deleteMessageLoggerMessage(conversationId, messageId) 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) @OptIn(ExperimentalTime::class)
override fun asyncOnActivityCreate() { override fun asyncOnActivityCreate() {
ConfigProperty.MESSAGE_LOGGER.valueContainer.addPropertyChangeListener { 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 if (context.config.options(ConfigProperty.AUTO_SAVE_MESSAGES).none { it.value }) return false
with(context.feature(Messaging::class)) { with(context.feature(Messaging::class)) {
if (lastOpenedConversationUUID == null) return@canSave false if (openedConversationUUID == null) return@canSave false
val conversation = lastOpenedConversationUUID.toString() val conversation = openedConversationUUID.toString()
if (context.feature(StealthMode::class).isStealth(conversation)) return@canSave false if (context.feature(StealthMode::class).isStealth(conversation)) return@canSave false
if (context.feature(AntiAutoSave::class).isConversationIgnored(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() val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build()
runCatching { runCatching {
fetchConversationWithMessagesPaginatedMethod.invoke( fetchConversationWithMessagesPaginatedMethod.invoke(
messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instanceNonNull(), messaging.conversationManager, messaging.openedConversationUUID!!.instanceNonNull(),
Long.MAX_VALUE, Long.MAX_VALUE,
3, 3,
callback callback

View File

@ -40,7 +40,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara
} }
"NOTE" -> { "NOTE" -> {
localMessageContent.contentType = ContentType.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) localMessageContent.content = MessageSender.audioNoteProto(mediaDuration)
} }
} }

View File

@ -7,6 +7,7 @@ import android.app.RemoteInput
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.os.UserHandle import android.os.UserHandle
import de.robv.android.xposed.XposedBridge 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.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.EncryptionUtils import me.rhunk.snapenhance.util.snap.EncryptionHelper
import me.rhunk.snapenhance.util.MediaDownloaderHelper import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.util.MediaType import me.rhunk.snapenhance.util.snap.MediaType
import me.rhunk.snapenhance.util.PreviewUtils import me.rhunk.snapenhance.util.snap.PreviewUtils
import me.rhunk.snapenhance.util.protobuf.ProtoReader import me.rhunk.snapenhance.util.protobuf.ProtoReader
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
@ -161,7 +162,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
notificationDataQueue.entries.onEach { (messageId, notificationData) -> notificationDataQueue.entries.onEach { (messageId, notificationData) ->
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return 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 contentType = snapMessage.messageContent.contentType
val contentData = snapMessage.messageContent.content 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 protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
runCatching { runCatching {
//download the media val messageReader = ProtoReader(contentData)
val mediaInfo = ProtoReader(contentData).let { val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
if (contentType == ContentType.EXTERNAL_MEDIA) EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false)
return@let it.readPath(*Constants.MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH) }
else
return@let it.readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH)
}?: return@runCatching
val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference, mergeOverlay = false, isPreviewMode = false) { var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[MediaType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX))
EncryptionUtils.decryptInputStream(it, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX) downloadedMediaList[MediaType.OVERLAY]?.let {
else it bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
}[MediaType.ORIGINAL] ?: throw Throwable("Failed to download media") }
val bitmapPreview = PreviewUtils.createPreview(downloadedMedia, mediaType.name.contains("VIDEO"))!!
val notificationBuilder = XposedHelpers.newInstance( val notificationBuilder = XposedHelpers.newInstance(
Notification.Builder::class.java, Notification.Builder::class.java,
context.androidContext, 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.tweaks.CameraTweaks
import me.rhunk.snapenhance.features.impl.ui.PinConversations import me.rhunk.snapenhance.features.impl.ui.PinConversations
import me.rhunk.snapenhance.features.impl.ui.UITweaks 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 me.rhunk.snapenhance.manager.Manager
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.reflect.KClass 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.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@ -18,7 +18,6 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Overlay
//TODO: Implement correctly
class MapActivity : Activity() { class MapActivity : Activity() {
private lateinit var mapView: MapView 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 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.annotation.SuppressLint
import android.content.res.ColorStateList 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.annotation.SuppressLint
import android.os.SystemClock 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.config.ConfigProperty
import me.rhunk.snapenhance.features.impl.Messaging import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu import me.rhunk.snapenhance.features.impl.spying.MessageLogger
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
class ChatActionMenu : AbstractMenu() { class ChatActionMenu : AbstractMenu() {
@ -84,7 +85,7 @@ class ChatActionMenu : AbstractMenu() {
closeActionMenu() closeActionMenu()
this@ChatActionMenu.context.executeAsync { this@ChatActionMenu.context.executeAsync {
with(this@ChatActionMenu.context.feature(Messaging::class)) { 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.annotation.SuppressLint
import android.app.AlertDialog 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.downloader.AntiAutoDownload
import me.rhunk.snapenhance.features.impl.spying.StealthMode import me.rhunk.snapenhance.features.impl.spying.StealthMode
import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.DateFormat import java.text.DateFormat
@ -59,9 +60,11 @@ class FriendFeedInfoMenu : AbstractMenu() {
try { try {
if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) {
icon = getImageDrawable( icon = getImageDrawable(
"https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId BitmojiSelfie.getBitmojiSelfie(
.toString() + "-" + profile.bitmojiAvatarId profile.bitmojiSelfieId.toString(),
.toString() + "-v1.webp?transparent=1&scale=0" profile.bitmojiAvatarId.toString(),
BitmojiSelfie.BitmojiSelfieType.THREE_D
)
) )
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -72,7 +75,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
val builder = AlertDialog.Builder(context.mainActivity) val builder = AlertDialog.Builder(context.mainActivity)
builder.setIcon(finalIcon) builder.setIcon(finalIcon)
builder.setTitle(profile.displayName) builder.setTitle(profile.displayName ?: profile.username)
val birthday = Calendar.getInstance() val birthday = Calendar.getInstance()
birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 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.annotation.SuppressLint
import android.view.Gravity import android.view.Gravity
@ -6,15 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import de.robv.android.xposed.XC_MethodHook.Unhook
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.config.ConfigProperty import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging 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.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import java.lang.reflect.Modifier 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)) context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME))
} }
private fun wasInjectedView(view: View): Boolean { private val fetchConversationHooks = mutableSetOf<Unhook>()
if (view.getTag(Constants.VIEW_INJECTED_CODE) != null) return true
view.setTag(Constants.VIEW_INJECTED_CODE, true) private fun unhookFetchConversation() {
return false fetchConversationHooks.let {
it.removeIf { hook -> hook.unhook() ; true}
}
} }
@SuppressLint("ResourceType") @SuppressLint("ResourceType")
@ -77,8 +76,8 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
return@hook return@hook
} }
//inject in group chat menus //TODO: inject in group chat menus
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchConversationUserUUID == null) { if (viewGroup.id == actionSheetContainer && childView.id == actionMenu) {
val injectedLayout = LinearLayout(childView.context).apply { val injectedLayout = LinearLayout(childView.context).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
gravity = Gravity.BOTTOM gravity = Gravity.BOTTOM
@ -86,10 +85,7 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
addView(childView) addView(childView)
} }
Hooker.ephemeralHook(context.classCache.conversationManager, "fetchConversation", HookStage.AFTER) { fun injectView() {
if (wasInjectedView(injectedLayout)) return@ephemeralHook
context.runOnUiThread {
val viewList = mutableListOf<View>() val viewList = mutableListOf<View>()
friendFeedInfoMenu.inject(injectedLayout) { view -> friendFeedInfoMenu.inject(injectedLayout) { view ->
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
@ -99,7 +95,6 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
} }
viewList.reversed().forEach { injectedLayout.addView(it, 0) } viewList.reversed().forEach { injectedLayout.addView(it, 0) }
} }
}
param.setArg(0, injectedLayout) param.setArg(0, injectedLayout)
} }
@ -127,20 +122,12 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
}) })
return@hook return@hook
} }
if (messaging.lastFetchConversationUserUUID == null) return@hook if (messaging.lastFetchConversationUUID == null || messaging.lastFetchConversationUserUUID == null) return@hook
//filter by the slot index //filter by the slot index
if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook
friendFeedInfoMenu.inject(viewGroup, originalAddView) 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.annotation.SuppressLint
import android.view.Gravity import android.view.Gravity
@ -10,8 +10,8 @@ import android.widget.ScrollView
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme
@SuppressLint("DiscouragedApi") @SuppressLint("DiscouragedApi")
class OperaContextActionMenu : AbstractMenu() { 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.annotation.SuppressLint
import android.app.AlertDialog 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.ConfigStateSelection
import me.rhunk.snapenhance.config.impl.ConfigStateValue import me.rhunk.snapenhance.config.impl.ConfigStateValue
import me.rhunk.snapenhance.config.impl.ConfigStringValue import me.rhunk.snapenhance.config.impl.ConfigStringValue
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu import me.rhunk.snapenhance.ui.menu.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
class SettingsMenu : AbstractMenu() { class SettingsMenu : AbstractMenu() {
@SuppressLint("ClickableViewAccessibility") @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
import me.rhunk.snapenhance.Logger.debug import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.ModContext
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.PrintWriter import java.io.PrintWriter
import java.net.ServerSocket import java.net.ServerSocket
@ -13,38 +12,23 @@ import java.util.Locale
import java.util.StringTokenizer import java.util.StringTokenizer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import java.util.function.Consumer
class DownloadServer( class DownloadServer {
private val context: ModContext
) {
private val port = ThreadLocalRandom.current().nextInt(10000, 65535) 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 private var serverSocket: ServerSocket? = null
fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) { fun ensureServerStarted(callback: DownloadServer.() -> Unit) {
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) {
if (serverSocket != null && !serverSocket!!.isClosed) { if (serverSocket != null && !serverSocket!!.isClosed) {
callback.run() callback(this)
return return
} }
Thread { Thread {
try { try {
debug("started web server on 127.0.0.1:$port") debug("started web server on 127.0.0.1:$port")
serverSocket = ServerSocket(port) serverSocket = ServerSocket(port)
callback.run() callback(this)
while (!serverSocket!!.isClosed) { while (!serverSocket!!.isClosed) {
try { try {
val socket = serverSocket!!.accept() val socket = serverSocket!!.accept()
@ -59,8 +43,10 @@ class DownloadServer(
}.start() }.start()
} }
fun putDownloadableContent(key: String, data: ByteArray) { fun putDownloadableContent(inputStream: InputStream): String {
cachedData[key] = data val key = System.nanoTime().toString(16)
cachedData[key] = inputStream
return "http://127.0.0.1:$port/$key"
} }
private fun handleRequest(socket: Socket) { private fun handleRequest(socket: Socket) {
@ -68,49 +54,58 @@ class DownloadServer(
val outputStream = socket.getOutputStream() val outputStream = socket.getOutputStream()
val writer = PrintWriter(outputStream) val writer = PrintWriter(outputStream)
val line = reader.readLine() ?: return val line = reader.readLine() ?: return
val close = Runnable { fun close() {
try { runCatching {
reader.close() reader.close()
writer.close() writer.close()
outputStream.close() outputStream.close()
socket.close() socket.close()
} catch (e: Throwable) { }.onFailure {
Logger.xposedLog(e) Logger.error("failed to close socket", it)
} }
} }
val parse = StringTokenizer(line) val parse = StringTokenizer(line)
val method = parse.nextToken().uppercase(Locale.getDefault()) val method = parse.nextToken().uppercase(Locale.getDefault())
var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
if (method != "GET") { if (method != "GET") {
writer.println("HTTP/1.1 501 Not Implemented") with(writer) {
writer.println("Content-type: " + "application/octet-stream") println("HTTP/1.1 501 Not Implemented")
writer.println("Content-length: " + 0) println("Content-type: " + "application/octet-stream")
writer.println() println("Content-length: " + 0)
writer.flush() println()
close.run() flush()
}
close()
return return
} }
if (fileRequested.startsWith("/")) { if (fileRequested.startsWith("/")) {
fileRequested = fileRequested.substring(1) fileRequested = fileRequested.substring(1)
} }
if (!cachedData.containsKey(fileRequested)) { if (!cachedData.containsKey(fileRequested)) {
writer.println("HTTP/1.1 404 Not Found") with(writer) {
writer.println("Content-type: " + "application/octet-stream") println("HTTP/1.1 404 Not Found")
writer.println("Content-length: " + 0) println("Content-type: " + "application/octet-stream")
writer.println() println("Content-length: " + 0)
writer.flush() println()
close.run() flush()
}
close()
return return
} }
val data = cachedData[fileRequested]!! val requestedData = cachedData[fileRequested]!!
writer.println("HTTP/1.1 200 OK") with(writer) {
writer.println("Content-type: " + "application/octet-stream") println("HTTP/1.1 200 OK")
writer.println("Content-length: " + data.size) println("Content-type: " + "application/octet-stream")
writer.println() println()
writer.flush() flush()
outputStream.write(data, 0, data.size) }
val buffer = ByteArray(1024)
var bytesRead: Int
while (requestedData.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
outputStream.flush() outputStream.flush()
close.run()
cachedData.remove(fileRequested) 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>