mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-23 18:16:15 +02:00
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:
parent
fca2f8a53d
commit
2ee64c40ad
@ -95,6 +95,7 @@ task getVersion {
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.21'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||
|
||||
compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
|
@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
@ -28,21 +30,27 @@
|
||||
|
||||
<service
|
||||
android:name=".bridge.service.BridgeService"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
</service>
|
||||
|
||||
<receiver android:name=".download.MediaDownloadReceiver" android:exported="true"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="me.rhunk.snapenhance.download.MediaDownloadReceiver.DOWNLOAD_ACTION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:name=".bridge.service.MainActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true">
|
||||
android:name=".ui.download.DownloadManagerActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".features.impl.ui.menus.MapActivity"
|
||||
android:name=".ui.map.MapActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true" />
|
||||
</application>
|
||||
|
@ -5,18 +5,13 @@ object Constants {
|
||||
const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android"
|
||||
|
||||
const val VIEW_INJECTED_CODE = 0x7FFFFF02
|
||||
const val VIEW_DRAWER = 0x7FFFFF03
|
||||
|
||||
val ARROYO_NOTE_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 6, 1, 1)
|
||||
val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1)
|
||||
val MESSAGE_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(11, 5, 1, 1)
|
||||
val MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(3, 3, 5, 1, 1)
|
||||
val ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 3, 3, 5, 1, 1)
|
||||
val ARROYO_STRING_CHAT_MESSAGE_PROTO = intArrayOf(4, 4, 2, 1)
|
||||
val ARROYO_MEDIA_CONTAINER_PROTO_PATH = intArrayOf(4, 4)
|
||||
val ARROYO_STRING_CHAT_MESSAGE_PROTO = ARROYO_MEDIA_CONTAINER_PROTO_PATH + intArrayOf(2, 1)
|
||||
val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3)
|
||||
|
||||
const val ARROYO_ENCRYPTION_PROTO_INDEX = 19
|
||||
const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4
|
||||
const val ENCRYPTION_PROTO_INDEX = 19
|
||||
const val ENCRYPTION_PROTO_INDEX_V2 = 4
|
||||
|
||||
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
}
|
@ -40,7 +40,7 @@ class ModContext {
|
||||
val config = ConfigManager(this)
|
||||
val actionManager = ActionManager(this)
|
||||
val database = DatabaseAccess(this)
|
||||
val downloadServer = DownloadServer(this)
|
||||
val downloadServer = DownloadServer()
|
||||
val messageSender = MessageSender(this)
|
||||
val classCache get() = SnapEnhance.classCache
|
||||
val resources: Resources get() = androidContext.resources
|
||||
|
@ -5,7 +5,7 @@ import android.os.Bundle
|
||||
import me.rhunk.snapenhance.BuildConfig
|
||||
import me.rhunk.snapenhance.action.AbstractAction
|
||||
import me.rhunk.snapenhance.config.ConfigProperty
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.MapActivity
|
||||
import me.rhunk.snapenhance.ui.map.MapActivity
|
||||
|
||||
class OpenMap: AbstractAction("action.open_map", dependsOnProperty = ConfigProperty.LOCATION_SPOOF) {
|
||||
override fun run() {
|
||||
|
@ -2,6 +2,7 @@ package me.rhunk.snapenhance.bridge
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
|
||||
import java.io.File
|
||||
|
||||
class MessageLoggerWrapper(
|
||||
@ -12,7 +13,14 @@ class MessageLoggerWrapper(
|
||||
|
||||
fun init() {
|
||||
database = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE)
|
||||
database.execSQL("CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, conversation_id VARCHAR, message_id BIGINT, message_data BLOB)")
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(database, mapOf(
|
||||
"messages" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"conversation_id VARCHAR",
|
||||
"message_id BIGINT",
|
||||
"message_data BLOB"
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fun deleteMessage(conversationId: String, messageId: Long) {
|
||||
|
@ -13,6 +13,8 @@ import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import me.rhunk.snapenhance.BuildConfig
|
||||
import me.rhunk.snapenhance.Logger.xposedLog
|
||||
import me.rhunk.snapenhance.bridge.AbstractBridgeClient
|
||||
@ -86,29 +88,21 @@ class ServiceBridgeClient: AbstractBridgeClient(), ServiceConnection {
|
||||
messageType: BridgeMessageType,
|
||||
bridgeMessage: BridgeMessage,
|
||||
resultType: KClass<T>? = null
|
||||
): T {
|
||||
val response = AtomicReference<BridgeMessage>()
|
||||
val condition = lock.newCondition()
|
||||
|
||||
with(Message.obtain()) {
|
||||
what = messageType.value
|
||||
replyTo = Messenger(object : Handler(handlerThread.looper) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
response.set(handleResponseMessage(msg))
|
||||
lock.withLock {
|
||||
condition.signal()
|
||||
) = runBlocking {
|
||||
return@runBlocking suspendCancellableCoroutine<T> { continuation ->
|
||||
with(Message.obtain()) {
|
||||
what = messageType.value
|
||||
replyTo = Messenger(object : Handler(handlerThread.looper) {
|
||||
override fun handleMessage(msg: Message) {
|
||||
if (continuation.isCompleted) return
|
||||
continuation.resumeWith(Result.success(handleResponseMessage(msg) as T))
|
||||
}
|
||||
}
|
||||
})
|
||||
data = Bundle()
|
||||
bridgeMessage.write(data)
|
||||
messenger.send(this)
|
||||
})
|
||||
data = Bundle()
|
||||
bridgeMessage.write(data)
|
||||
messenger.send(this)
|
||||
}
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
condition.awaitUninterruptibly()
|
||||
}
|
||||
return response.get() as T
|
||||
}
|
||||
|
||||
override fun createAndReadFile(
|
||||
|
@ -1,19 +1,23 @@
|
||||
package me.rhunk.snapenhance.data
|
||||
|
||||
import java.io.File
|
||||
|
||||
enum class FileType(
|
||||
val fileExtension: String? = null,
|
||||
val mimeType: String,
|
||||
val isVideo: Boolean = false,
|
||||
val isImage: Boolean = false,
|
||||
val isAudio: Boolean = false
|
||||
) {
|
||||
GIF("gif", false, false, false),
|
||||
PNG("png", false, true, false),
|
||||
MP4("mp4", true, false, false),
|
||||
MP3("mp3", false, false, true),
|
||||
JPG("jpg", false, true, false),
|
||||
ZIP("zip", false, false, false),
|
||||
WEBP("webp", false, true, false),
|
||||
UNKNOWN("dat", false, false, false);
|
||||
GIF("gif", "image/gif", false, false, false),
|
||||
PNG("png", "image/png", false, true, false),
|
||||
MP4("mp4", "video/mp4", true, false, false),
|
||||
MP3("mp3", "audio/mp3",false, false, true),
|
||||
JPG("jpg", "image/jpg",false, true, false),
|
||||
ZIP("zip", "application/zip", false, false, false),
|
||||
WEBP("webp", "image/webp", false, true, false),
|
||||
MPD("mpd", "text/xml", false, false, false),
|
||||
UNKNOWN("dat", "application/octet-stream", false, false, false);
|
||||
|
||||
companion object {
|
||||
private val fileSignatures = HashMap<String, FileType>()
|
||||
@ -40,6 +44,14 @@ enum class FileType(
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
fun fromFile(file: File): FileType {
|
||||
file.inputStream().use { inputStream ->
|
||||
val buffer = ByteArray(16)
|
||||
inputStream.read(buffer)
|
||||
return fromByteArray(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
fun fromByteArray(array: ByteArray): FileType {
|
||||
val headerBytes = ByteArray(16)
|
||||
System.arraycopy(array, 0, headerBytes, 0, 16)
|
||||
|
@ -31,7 +31,7 @@ class MessageSender(
|
||||
}.toByteArray()
|
||||
}
|
||||
|
||||
val audioNoteProto: (Int) -> ByteArray = { duration ->
|
||||
val audioNoteProto: (Long) -> ByteArray = { duration ->
|
||||
ProtoWriter().apply {
|
||||
write(6, 1) {
|
||||
write(1) {
|
||||
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -6,10 +6,13 @@ import me.rhunk.snapenhance.features.Feature
|
||||
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import me.rhunk.snapenhance.hook.hook
|
||||
import me.rhunk.snapenhance.util.getObjectField
|
||||
|
||||
class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
|
||||
lateinit var conversationManager: Any
|
||||
|
||||
var openedConversationUUID: SnapUUID? = null
|
||||
var lastOpenedConversationUUID: SnapUUID? = null
|
||||
var lastFetchConversationUserUUID: SnapUUID? = null
|
||||
var lastFetchConversationUUID: SnapUUID? = null
|
||||
@ -22,24 +25,22 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
|
||||
}
|
||||
|
||||
override fun onActivityCreate() {
|
||||
context.mappings.getMappedClass("callbacks", "GetOneOnOneConversationIdsCallback").hook("onSuccess", HookStage.BEFORE) { param ->
|
||||
val userIdToConversation = (param.arg<ArrayList<*>>(0))
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.get(0) ?: return@hook
|
||||
|
||||
lastFetchConversationUUID = SnapUUID(userIdToConversation.getObjectField("mConversationId"))
|
||||
lastFetchConversationUserUUID = SnapUUID(userIdToConversation.getObjectField("mUserId"))
|
||||
}
|
||||
|
||||
with(context.classCache.conversationManager) {
|
||||
Hooker.hook(this, "enterConversation", HookStage.BEFORE) {
|
||||
lastOpenedConversationUUID = SnapUUID(it.arg(0))
|
||||
}
|
||||
|
||||
Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param ->
|
||||
val conversationIds: List<Any> = param.arg(0)
|
||||
if (conversationIds.isNotEmpty()) {
|
||||
lastFetchConversationUserUUID = SnapUUID(conversationIds[0])
|
||||
}
|
||||
openedConversationUUID = SnapUUID(it.arg(0))
|
||||
}
|
||||
|
||||
Hooker.hook(this, "exitConversation", HookStage.BEFORE) {
|
||||
lastOpenedConversationUUID = null
|
||||
}
|
||||
|
||||
Hooker.hook(this, "fetchConversation", HookStage.BEFORE) {
|
||||
lastFetchConversationUUID = SnapUUID(it.arg(0))
|
||||
openedConversationUUID = null
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +55,7 @@ class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_C
|
||||
|
||||
//get last opened snap for media downloader
|
||||
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param ->
|
||||
lastOpenedConversationUUID = SnapUUID(param.arg(1))
|
||||
openedConversationUUID = SnapUUID(param.arg(1))
|
||||
lastFocusedMessageId = param.arg(2)
|
||||
}
|
||||
|
||||
|
@ -3,11 +3,11 @@ package me.rhunk.snapenhance.features.impl.downloader
|
||||
import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaScannerConnection
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.widget.ImageView
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
|
||||
import me.rhunk.snapenhance.Logger.xposedLog
|
||||
import me.rhunk.snapenhance.config.ConfigProperty
|
||||
@ -18,6 +18,10 @@ import me.rhunk.snapenhance.data.wrapper.impl.media.dash.LongformVideoPlaylistIt
|
||||
import me.rhunk.snapenhance.data.wrapper.impl.media.dash.SnapPlaylistItem
|
||||
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
|
||||
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap
|
||||
import me.rhunk.snapenhance.database.objects.FriendInfo
|
||||
import me.rhunk.snapenhance.download.ClientDownloadManager
|
||||
import me.rhunk.snapenhance.download.data.toKeyPair
|
||||
import me.rhunk.snapenhance.download.enums.DownloadMediaType
|
||||
import me.rhunk.snapenhance.features.Feature
|
||||
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||
import me.rhunk.snapenhance.features.impl.Messaging
|
||||
@ -25,33 +29,23 @@ import me.rhunk.snapenhance.features.impl.spying.MessageLogger
|
||||
import me.rhunk.snapenhance.hook.HookAdapter
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import me.rhunk.snapenhance.util.EncryptionUtils
|
||||
import me.rhunk.snapenhance.util.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.util.MediaType
|
||||
import me.rhunk.snapenhance.util.PreviewUtils
|
||||
import me.rhunk.snapenhance.util.download.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.util.snap.EncryptionHelper
|
||||
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.util.snap.MediaType
|
||||
import me.rhunk.snapenhance.util.snap.PreviewUtils
|
||||
import me.rhunk.snapenhance.util.getObjectField
|
||||
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||
import java.io.ByteArrayOutputStream
|
||||
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.file.Paths
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Arrays
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import javax.xml.transform.TransformerFactory
|
||||
import javax.xml.transform.dom.DOMSource
|
||||
import javax.xml.transform.stream.StreamResult
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.io.path.inputStream
|
||||
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
|
||||
private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null
|
||||
private var lastSeenMapParams: ParamMap? = null
|
||||
@ -59,12 +53,38 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
runCatching { FFmpegKit.execute("-version") }.isSuccess
|
||||
}
|
||||
|
||||
private fun provideClientDownloadManager(
|
||||
pathSuffix: String,
|
||||
mediaDisplaySource: String? = null,
|
||||
mediaDisplayType: String? = null,
|
||||
friendInfo: FriendInfo? = null
|
||||
): ClientDownloadManager {
|
||||
val iconUrl = friendInfo?.takeIf {
|
||||
it.bitmojiAvatarId != null && it.bitmojiSelfieId != null
|
||||
}?.let {
|
||||
BitmojiSelfie.getBitmojiSelfie(it.bitmojiSelfieId!!, it.bitmojiAvatarId!!, BitmojiSelfie.BitmojiSelfieType.THREE_D)
|
||||
}
|
||||
|
||||
val outputPath = File(
|
||||
context.config.string(ConfigProperty.SAVE_FOLDER),
|
||||
createNewFilePath(pathSuffix.hashCode(), pathSuffix)
|
||||
).absolutePath
|
||||
|
||||
return ClientDownloadManager(
|
||||
context = context,
|
||||
mediaDisplaySource = mediaDisplaySource,
|
||||
mediaDisplayType = mediaDisplayType,
|
||||
iconUrl = iconUrl,
|
||||
outputPath = outputPath
|
||||
)
|
||||
}
|
||||
|
||||
private fun canMergeOverlay(): Boolean {
|
||||
if (context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)["merge_overlay"] == false) return false
|
||||
return isFFmpegPresent
|
||||
}
|
||||
|
||||
private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String {
|
||||
private fun createNewFilePath(hash: Int, pathPrefix: String): String {
|
||||
val hexHash = Integer.toHexString(hash)
|
||||
val downloadOptions = context.config.options(ConfigProperty.DOWNLOAD_OPTIONS)
|
||||
|
||||
@ -81,13 +101,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
}
|
||||
|
||||
if (downloadOptions["format_user_folder"] == true) {
|
||||
finalPath.append(author).append("/")
|
||||
finalPath.append(pathPrefix).append("/")
|
||||
}
|
||||
if (downloadOptions["format_hash"] == true) {
|
||||
appendFileName(hexHash)
|
||||
}
|
||||
if (downloadOptions["format_username"] == true) {
|
||||
appendFileName(author)
|
||||
appendFileName(pathPrefix)
|
||||
}
|
||||
if (downloadOptions["format_date_time"] == true) {
|
||||
appendFileName(currentDateTime)
|
||||
@ -95,79 +115,9 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
|
||||
if (finalPath.isEmpty()) finalPath.append(hexHash)
|
||||
|
||||
return finalPath.toString() + "." + fileType.fileExtension
|
||||
return finalPath.toString()
|
||||
}
|
||||
|
||||
private fun downloadFile(outputFile: File, content: ByteArray): Boolean {
|
||||
val onDownloadComplete = {
|
||||
context.shortToast(
|
||||
"Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "")
|
||||
.substring(1)
|
||||
)
|
||||
}
|
||||
if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) {
|
||||
try {
|
||||
val fos = FileOutputStream(outputFile)
|
||||
fos.write(content)
|
||||
fos.close()
|
||||
MediaScannerConnection.scanFile(
|
||||
context.androidContext,
|
||||
arrayOf(outputFile.absolutePath),
|
||||
null,
|
||||
null
|
||||
)
|
||||
onDownloadComplete()
|
||||
} catch (e: Throwable) {
|
||||
xposedLog(e)
|
||||
context.longToast("Failed to save file: " + e.message)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
context.downloadServer.startFileDownload(outputFile, content) { result ->
|
||||
if (result) {
|
||||
onDownloadComplete()
|
||||
return@startFileDownload
|
||||
}
|
||||
context.longToast("Failed to save file. Check logs for more info.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
private fun queryMediaData(mediaInfo: MediaInfo): ByteArray {
|
||||
val mediaUri = Uri.parse(mediaInfo.uri)
|
||||
val mediaInputStream = AtomicReference<InputStream>()
|
||||
if (mediaUri.scheme == "file") {
|
||||
mediaInputStream.set(Paths.get(mediaUri.path).inputStream())
|
||||
} else {
|
||||
val url = URL(mediaUri.toString())
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connection.connect()
|
||||
mediaInputStream.set(connection.inputStream)
|
||||
}
|
||||
mediaInfo.encryption?.let { encryption ->
|
||||
mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE)))
|
||||
}
|
||||
return mediaInputStream.get().readBytes()
|
||||
}
|
||||
|
||||
private fun createNeededDirectories(file: File): File {
|
||||
val directory = file.parentFile ?: return file
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean {
|
||||
val fileName: String = createNewFilePath(hash, author, fileType)
|
||||
val outputFile: File =
|
||||
createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
|
||||
return outputFile.exists()
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Download the last seen media
|
||||
*/
|
||||
@ -178,41 +128,46 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) {
|
||||
if (mediaInfoMap.isEmpty()) return
|
||||
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
|
||||
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
|
||||
context.shortToast("Downloading split snap")
|
||||
}
|
||||
var mediaContent: ByteArray? = queryMediaData(originalMediaInfo)
|
||||
val hash = Arrays.hashCode(mediaContent)
|
||||
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
|
||||
//prevent converting the same media twice
|
||||
if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) {
|
||||
context.shortToast("Media already exists")
|
||||
return
|
||||
private fun handleLocalReferences(path: String) = runBlocking {
|
||||
Uri.parse(path).let { uri ->
|
||||
if (uri.scheme == "file") {
|
||||
return@let suspendCoroutine<String> { continuation ->
|
||||
context.downloadServer.ensureServerStarted {
|
||||
val url = putDownloadableContent(Paths.get(uri.path).inputStream())
|
||||
continuation.resumeWith(Result.success(url))
|
||||
}
|
||||
}
|
||||
}
|
||||
val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!!
|
||||
val overlayContent: ByteArray = queryMediaData(overlayMediaInfo)
|
||||
mediaContent = MediaDownloaderHelper.mergeOverlay(mediaContent, overlayContent, false)
|
||||
path
|
||||
}
|
||||
val fileType = FileType.fromByteArray(mediaContent!!)
|
||||
downloadMediaContent(mediaContent, hash, author, fileType)
|
||||
}
|
||||
|
||||
private fun downloadMediaContent(
|
||||
data: ByteArray,
|
||||
hash: Int,
|
||||
messageAuthor: String,
|
||||
fileType: FileType
|
||||
): Boolean {
|
||||
val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false
|
||||
val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
|
||||
if (outputFile.exists()) {
|
||||
context.shortToast("Media already exists")
|
||||
return false
|
||||
private fun downloadOperaMedia(clientDownloadManager: ClientDownloadManager, mediaInfoMap: Map<MediaType, MediaInfo>) {
|
||||
if (mediaInfoMap.isEmpty()) return
|
||||
|
||||
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
|
||||
val overlay = mediaInfoMap[MediaType.OVERLAY]
|
||||
|
||||
val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri)
|
||||
val overlayReference = overlay?.let { handleLocalReferences(it.uri) }
|
||||
|
||||
overlay?.let {
|
||||
clientDownloadManager.downloadMediaWithOverlay(
|
||||
originalMediaInfoReference,
|
||||
overlayReference!!,
|
||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||
DownloadMediaType.fromUri(Uri.parse(overlayReference)),
|
||||
videoEncryption = originalMediaInfo.encryption?.toKeyPair(),
|
||||
overlayEncryption = overlay.encryption?.toKeyPair()
|
||||
)
|
||||
return
|
||||
}
|
||||
return downloadFile(outputFile, data)
|
||||
|
||||
clientDownloadManager.downloadMedia(
|
||||
originalMediaInfoReference,
|
||||
DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)),
|
||||
originalMediaInfo.encryption?.toKeyPair()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -236,8 +191,9 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
return
|
||||
}
|
||||
|
||||
val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!!
|
||||
downloadOperaMedia(mediaInfoMap, author)
|
||||
val author = context.database.getFriendInfo(senderId) ?: return
|
||||
val authorUsername = author.usernameForSorting!!
|
||||
downloadOperaMedia(provideClientDownloadManager(authorUsername, authorUsername, "Chat Media", friendInfo = author), mediaInfoMap)
|
||||
return
|
||||
}
|
||||
|
||||
@ -250,9 +206,10 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
storyIdStartIndex,
|
||||
playlistGroup.indexOf(",", storyIdStartIndex)
|
||||
)
|
||||
val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId)
|
||||
val author = context.database.getFriendInfo(if (storyUserId == "null") context.database.getMyUserId()!! else storyUserId) ?: return
|
||||
val authorName = author.usernameForSorting!!
|
||||
|
||||
downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!)
|
||||
downloadOperaMedia(provideClientDownloadManager(authorName, authorName, "Story", friendInfo = author), mediaInfoMap, )
|
||||
return
|
||||
}
|
||||
|
||||
@ -264,13 +221,13 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace(
|
||||
"[^\\x00-\\x7F]".toRegex(),
|
||||
"")
|
||||
downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName")
|
||||
downloadOperaMedia(provideClientDownloadManager("Public-Stories/$userDisplayName", userDisplayName, "Public Story"), mediaInfoMap)
|
||||
return
|
||||
}
|
||||
|
||||
//spotlight
|
||||
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) {
|
||||
downloadOperaMedia(mediaInfoMap, "Spotlight")
|
||||
downloadOperaMedia(provideClientDownloadManager("Spotlight", mediaDisplayType = "Spotlight", mediaDisplaySource = paramMap["TIME_STAMP"].toString()), mediaInfoMap)
|
||||
return
|
||||
}
|
||||
|
||||
@ -302,24 +259,12 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
|
||||
//get the mpd playlist and append the cdn url to baseurl nodes
|
||||
val playlistUrl = paramMap["MEDIA_ID"].toString().let { it.substring(it.indexOf("https://cf-st.sc-cdn.net")) }
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(URL(playlistUrl).openStream())
|
||||
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
|
||||
for (i in 0 until baseUrlNodeList.length) {
|
||||
val baseUrlNode = baseUrlNodeList.item(i)
|
||||
val baseUrl = baseUrlNode.textContent
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
}
|
||||
|
||||
val xmlData = ByteArrayOutputStream()
|
||||
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(xmlData))
|
||||
runCatching {
|
||||
context.shortToast("Downloading dash media. This might take a while...")
|
||||
val downloadedMedia = MediaDownloaderHelper.downloadDashChapter(xmlData.toByteArray().toString(Charsets.UTF_8), snapChapterTimestamp, duration)
|
||||
downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), "Pro-Stories/${storyName}", FileType.fromByteArray(downloadedMedia))
|
||||
}.onFailure {
|
||||
context.longToast("Failed to download media: ${it.message}")
|
||||
xposedLog(it)
|
||||
}
|
||||
val clientDownloadManager = provideClientDownloadManager("Pro-Stories/${storyName}", storyName, "Pro Story")
|
||||
clientDownloadManager.downloadDashMedia(
|
||||
playlistUrl,
|
||||
snapChapterTimestamp,
|
||||
duration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -383,60 +328,103 @@ class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParam
|
||||
fun onMessageActionMenu(isPreviewMode: Boolean) {
|
||||
//check if the message was focused in a conversation
|
||||
val messaging = context.feature(Messaging::class)
|
||||
if (messaging.lastOpenedConversationUUID == null) return
|
||||
val messageLogger = context.feature(MessageLogger::class)
|
||||
|
||||
if (messaging.openedConversationUUID == null) return
|
||||
val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return
|
||||
|
||||
//get the message author
|
||||
val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!!
|
||||
val friendInfo: FriendInfo = context.database.getFriendInfo(message.sender_id!!)!!
|
||||
val authorName = friendInfo.usernameForSorting!!
|
||||
|
||||
var messageContent = message.message_content!!
|
||||
var isArroyoMessage = true
|
||||
var deletedMediaReference: ByteArray? = null
|
||||
|
||||
//check if the messageId
|
||||
val contentType: ContentType = ContentType.fromId(message.content_type)
|
||||
if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) {
|
||||
context.shortToast("Preview/Download are not yet available for deleted messages")
|
||||
return
|
||||
var contentType: ContentType = ContentType.fromId(message.content_type)
|
||||
|
||||
if (messageLogger.isMessageRemoved(message.client_message_id.toLong())) {
|
||||
val messageObject = messageLogger.getMessageObject(message.client_conversation_id!!, message.client_message_id.toLong()) ?: throw Exception("Message not found in database")
|
||||
isArroyoMessage = false
|
||||
val messageContentObject = messageObject.getAsJsonObject("mMessageContent")
|
||||
|
||||
messageContent = messageContentObject
|
||||
.getAsJsonArray("mContent")
|
||||
.map { it.asByte }
|
||||
.toByteArray()
|
||||
|
||||
contentType = ContentType.valueOf(messageContentObject
|
||||
.getAsJsonPrimitive("mContentType").asString
|
||||
)
|
||||
|
||||
deletedMediaReference = messageContentObject.getAsJsonArray("mRemoteMediaReferences")
|
||||
.map { it.asJsonObject.getAsJsonArray("mMediaReferences") }
|
||||
.flatten().let { reference ->
|
||||
if (reference.isEmpty()) return@let null
|
||||
reference[0].asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType != ContentType.NOTE &&
|
||||
contentType != ContentType.SNAP &&
|
||||
contentType != ContentType.EXTERNAL_MEDIA) {
|
||||
context.shortToast("Unsupported content type $contentType")
|
||||
return
|
||||
}
|
||||
val messageReader = ProtoReader(message.message_content!!)
|
||||
val urlProto: ByteArray = messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!!
|
||||
|
||||
//download the message content
|
||||
try {
|
||||
val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(urlProto, canMergeOverlay(), isPreviewMode) {
|
||||
EncryptionUtils.decryptInputStreamFromArroyo(it, contentType, messageReader)
|
||||
}[MediaType.ORIGINAL] ?: throw Exception("Failed to download media")
|
||||
val fileType = FileType.fromByteArray(downloadedMedia)
|
||||
val messageReader = ProtoReader(messageContent)
|
||||
val urlProto: ByteArray = if (isArroyoMessage) {
|
||||
messageReader.getByteArray(*ARROYO_URL_KEY_PROTO_PATH)!!
|
||||
} else {
|
||||
deletedMediaReference!!
|
||||
}
|
||||
|
||||
if (isPreviewMode) {
|
||||
runCatching {
|
||||
val bitmap: Bitmap? = PreviewUtils.createPreview(downloadedMedia, fileType.isVideo)
|
||||
if (bitmap == null) {
|
||||
context.shortToast("Failed to create preview")
|
||||
return
|
||||
}
|
||||
val builder = AlertDialog.Builder(context.mainActivity)
|
||||
builder.setTitle("Preview")
|
||||
val imageView = ImageView(builder.context)
|
||||
imageView.setImageBitmap(bitmap)
|
||||
builder.setView(imageView)
|
||||
builder.setPositiveButton(
|
||||
"Close"
|
||||
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||
context.runOnUiThread { builder.show() }
|
||||
}.onFailure {
|
||||
context.shortToast("Failed to create preview: $it")
|
||||
xposedLog(it)
|
||||
}
|
||||
runCatching {
|
||||
if (!isPreviewMode) {
|
||||
val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage)
|
||||
provideClientDownloadManager(authorName, authorName, "Chat Media", friendInfo = friendInfo).downloadMedia(
|
||||
Base64.UrlSafe.encode(urlProto),
|
||||
DownloadMediaType.PROTO_MEDIA,
|
||||
encryption = encryptionKeys?.toKeyPair()
|
||||
)
|
||||
return
|
||||
}
|
||||
downloadMediaContent(downloadedMedia, downloadedMedia.contentHashCode(), messageAuthor, fileType)
|
||||
} catch (e: Throwable) {
|
||||
context.longToast("Failed to download " + e.message)
|
||||
xposedLog(e)
|
||||
|
||||
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(urlProto) {
|
||||
EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = isArroyoMessage)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val originalMedia = downloadedMediaList[MediaType.ORIGINAL] ?: return
|
||||
val overlay = downloadedMediaList[MediaType.OVERLAY]
|
||||
|
||||
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
|
||||
|
||||
if (bitmap == null) {
|
||||
context.shortToast("Failed to create preview")
|
||||
return
|
||||
}
|
||||
|
||||
overlay?.let {
|
||||
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
|
||||
}
|
||||
|
||||
with(AlertDialog.Builder(context.mainActivity)) {
|
||||
setTitle("Preview")
|
||||
setView(ImageView(context).apply {
|
||||
setImageBitmap(bitmap)
|
||||
})
|
||||
setPositiveButton("Close") { dialog: DialogInterface, _: Int -> dialog.dismiss() }
|
||||
this@MediaDownloader.context.runOnUiThread { show() }
|
||||
}
|
||||
}.onFailure {
|
||||
context.shortToast("Failed to create preview: $it")
|
||||
xposedLog(it)
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast("Failed to download " + it.message)
|
||||
xposedLog(it)
|
||||
}
|
||||
}
|
||||
}
|
@ -37,6 +37,15 @@ class MessageLogger : Feature("MessageLogger",
|
||||
context.bridgeClient.deleteMessageLoggerMessage(conversationId, messageId)
|
||||
}
|
||||
|
||||
fun getMessageObject(conversationId: String, messageId: Long): JsonObject? {
|
||||
if (deletedMessageCache.containsKey(messageId)) {
|
||||
return deletedMessageCache[messageId]
|
||||
}
|
||||
return context.bridgeClient.getMessageLoggerMessage(conversationId, messageId)?.let {
|
||||
JsonParser.parseString(it.toString(Charsets.UTF_8)).asJsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun asyncOnActivityCreate() {
|
||||
ConfigProperty.MESSAGE_LOGGER.valueContainer.addPropertyChangeListener {
|
||||
|
@ -69,8 +69,8 @@ class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CR
|
||||
if (context.config.options(ConfigProperty.AUTO_SAVE_MESSAGES).none { it.value }) return false
|
||||
|
||||
with(context.feature(Messaging::class)) {
|
||||
if (lastOpenedConversationUUID == null) return@canSave false
|
||||
val conversation = lastOpenedConversationUUID.toString()
|
||||
if (openedConversationUUID == null) return@canSave false
|
||||
val conversation = openedConversationUUID.toString()
|
||||
if (context.feature(StealthMode::class).isStealth(conversation)) return@canSave false
|
||||
if (context.feature(AntiAutoSave::class).isConversationIgnored(conversation)) return@canSave false
|
||||
}
|
||||
@ -120,7 +120,7 @@ class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CR
|
||||
val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build()
|
||||
runCatching {
|
||||
fetchConversationWithMessagesPaginatedMethod.invoke(
|
||||
messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instanceNonNull(),
|
||||
messaging.conversationManager, messaging.openedConversationUUID!!.instanceNonNull(),
|
||||
Long.MAX_VALUE,
|
||||
3,
|
||||
callback
|
||||
|
@ -40,7 +40,7 @@ class GalleryMediaSendOverride : Feature("Gallery Media Send Override", loadPara
|
||||
}
|
||||
"NOTE" -> {
|
||||
localMessageContent.contentType = ContentType.NOTE
|
||||
val mediaDuration = messageProtoReader.getInt(3, 3, 5, 1, 1, 15) ?: 0
|
||||
val mediaDuration = messageProtoReader.getLong(3, 3, 5, 1, 1, 15) ?: 0
|
||||
localMessageContent.content = MessageSender.audioNoteProto(mediaDuration)
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import android.app.RemoteInput
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.os.UserHandle
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
@ -24,10 +25,10 @@ import me.rhunk.snapenhance.features.impl.Messaging
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import me.rhunk.snapenhance.util.CallbackBuilder
|
||||
import me.rhunk.snapenhance.util.EncryptionUtils
|
||||
import me.rhunk.snapenhance.util.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.util.MediaType
|
||||
import me.rhunk.snapenhance.util.PreviewUtils
|
||||
import me.rhunk.snapenhance.util.snap.EncryptionHelper
|
||||
import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.util.snap.MediaType
|
||||
import me.rhunk.snapenhance.util.snap.PreviewUtils
|
||||
import me.rhunk.snapenhance.util.protobuf.ProtoReader
|
||||
|
||||
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||
@ -161,7 +162,11 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
|
||||
|
||||
notificationDataQueue.entries.onEach { (messageId, notificationData) ->
|
||||
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return
|
||||
val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage")
|
||||
val senderUsername by lazy {
|
||||
context.database.getFriendInfo(snapMessage.senderId.toString())?.let {
|
||||
it.displayName ?: it.username
|
||||
}
|
||||
}
|
||||
|
||||
val contentType = snapMessage.messageContent.contentType
|
||||
val contentData = snapMessage.messageContent.content
|
||||
@ -192,21 +197,17 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
|
||||
val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
|
||||
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
|
||||
runCatching {
|
||||
//download the media
|
||||
val mediaInfo = ProtoReader(contentData).let {
|
||||
if (contentType == ContentType.EXTERNAL_MEDIA)
|
||||
return@let it.readPath(*Constants.MESSAGE_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH)
|
||||
else
|
||||
return@let it.readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH)
|
||||
}?: return@runCatching
|
||||
val messageReader = ProtoReader(contentData)
|
||||
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
|
||||
EncryptionHelper.decryptInputStream(it, contentType, messageReader, isArroyo = false)
|
||||
}
|
||||
|
||||
val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference, mergeOverlay = false, isPreviewMode = false) {
|
||||
if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX))
|
||||
EncryptionUtils.decryptInputStream(it, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX)
|
||||
else it
|
||||
}[MediaType.ORIGINAL] ?: throw Throwable("Failed to download media")
|
||||
var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[MediaType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
|
||||
|
||||
downloadedMediaList[MediaType.OVERLAY]?.let {
|
||||
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
|
||||
}
|
||||
|
||||
val bitmapPreview = PreviewUtils.createPreview(downloadedMedia, mediaType.name.contains("VIDEO"))!!
|
||||
val notificationBuilder = XposedHelpers.newInstance(
|
||||
Notification.Builder::class.java,
|
||||
context.androidContext,
|
||||
|
@ -31,7 +31,7 @@ import me.rhunk.snapenhance.features.impl.spying.StealthMode
|
||||
import me.rhunk.snapenhance.features.impl.tweaks.CameraTweaks
|
||||
import me.rhunk.snapenhance.features.impl.ui.PinConversations
|
||||
import me.rhunk.snapenhance.features.impl.ui.UITweaks
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector
|
||||
import me.rhunk.snapenhance.ui.menu.impl.MenuViewInjector
|
||||
import me.rhunk.snapenhance.manager.Manager
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.reflect.KClass
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||
package me.rhunk.snapenhance.ui.map
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
@ -18,7 +18,6 @@ import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
|
||||
|
||||
//TODO: Implement correctly
|
||||
class MapActivity : Activity() {
|
||||
|
||||
private lateinit var mapView: MapView
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||
package me.rhunk.snapenhance.ui.menu
|
||||
|
||||
import me.rhunk.snapenhance.ModContext
|
||||
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||
package me.rhunk.snapenhance.ui.menu
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.ColorStateList
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||
package me.rhunk.snapenhance.ui.menu.impl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.SystemClock
|
||||
@ -11,8 +11,9 @@ import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
|
||||
import me.rhunk.snapenhance.config.ConfigProperty
|
||||
import me.rhunk.snapenhance.features.impl.Messaging
|
||||
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.features.impl.spying.MessageLogger
|
||||
import me.rhunk.snapenhance.ui.menu.AbstractMenu
|
||||
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
|
||||
|
||||
|
||||
class ChatActionMenu : AbstractMenu() {
|
||||
@ -84,7 +85,7 @@ class ChatActionMenu : AbstractMenu() {
|
||||
closeActionMenu()
|
||||
this@ChatActionMenu.context.executeAsync {
|
||||
with(this@ChatActionMenu.context.feature(Messaging::class)) {
|
||||
context.feature(me.rhunk.snapenhance.features.impl.spying.MessageLogger::class).deleteMessage(lastOpenedConversationUUID.toString(), lastFocusedMessageId)
|
||||
context.feature(MessageLogger::class).deleteMessage(openedConversationUUID.toString(), lastFocusedMessageId)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||
package me.rhunk.snapenhance.ui.menu.impl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
@ -32,8 +32,9 @@ import me.rhunk.snapenhance.features.impl.Messaging
|
||||
import me.rhunk.snapenhance.features.impl.downloader.AntiAutoDownload
|
||||
import me.rhunk.snapenhance.features.impl.spying.StealthMode
|
||||
import me.rhunk.snapenhance.features.impl.tweaks.AntiAutoSave
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.ui.menu.AbstractMenu
|
||||
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.util.snap.BitmojiSelfie
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.DateFormat
|
||||
@ -59,9 +60,11 @@ class FriendFeedInfoMenu : AbstractMenu() {
|
||||
try {
|
||||
if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) {
|
||||
icon = getImageDrawable(
|
||||
"https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId
|
||||
.toString() + "-" + profile.bitmojiAvatarId
|
||||
.toString() + "-v1.webp?transparent=1&scale=0"
|
||||
BitmojiSelfie.getBitmojiSelfie(
|
||||
profile.bitmojiSelfieId.toString(),
|
||||
profile.bitmojiAvatarId.toString(),
|
||||
BitmojiSelfie.BitmojiSelfieType.THREE_D
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@ -72,7 +75,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
|
||||
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
|
||||
val builder = AlertDialog.Builder(context.mainActivity)
|
||||
builder.setIcon(finalIcon)
|
||||
builder.setTitle(profile.displayName)
|
||||
builder.setTitle(profile.displayName ?: profile.username)
|
||||
|
||||
val birthday = Calendar.getInstance()
|
||||
birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus
|
||||
package me.rhunk.snapenhance.ui.menu.impl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Gravity
|
||||
@ -6,15 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import de.robv.android.xposed.XC_MethodHook.Unhook
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.config.ConfigProperty
|
||||
import me.rhunk.snapenhance.features.Feature
|
||||
import me.rhunk.snapenhance.features.FeatureLoadParams
|
||||
import me.rhunk.snapenhance.features.impl.Messaging
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.impl.ChatActionMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.impl.FriendFeedInfoMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.impl.OperaContextActionMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.impl.SettingsMenu
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import java.lang.reflect.Modifier
|
||||
@ -30,10 +27,12 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
|
||||
context.resources.getString(context.resources.getIdentifier("new_chat", "string", Constants.SNAPCHAT_PACKAGE_NAME))
|
||||
}
|
||||
|
||||
private fun wasInjectedView(view: View): Boolean {
|
||||
if (view.getTag(Constants.VIEW_INJECTED_CODE) != null) return true
|
||||
view.setTag(Constants.VIEW_INJECTED_CODE, true)
|
||||
return false
|
||||
private val fetchConversationHooks = mutableSetOf<Unhook>()
|
||||
|
||||
private fun unhookFetchConversation() {
|
||||
fetchConversationHooks.let {
|
||||
it.removeIf { hook -> hook.unhook() ; true}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ResourceType")
|
||||
@ -77,8 +76,8 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
|
||||
return@hook
|
||||
}
|
||||
|
||||
//inject in group chat menus
|
||||
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu && messaging.lastFetchConversationUserUUID == null) {
|
||||
//TODO: inject in group chat menus
|
||||
if (viewGroup.id == actionSheetContainer && childView.id == actionMenu) {
|
||||
val injectedLayout = LinearLayout(childView.context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.BOTTOM
|
||||
@ -86,19 +85,15 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
|
||||
addView(childView)
|
||||
}
|
||||
|
||||
Hooker.ephemeralHook(context.classCache.conversationManager, "fetchConversation", HookStage.AFTER) {
|
||||
if (wasInjectedView(injectedLayout)) return@ephemeralHook
|
||||
|
||||
context.runOnUiThread {
|
||||
val viewList = mutableListOf<View>()
|
||||
friendFeedInfoMenu.inject(injectedLayout) { view ->
|
||||
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, 10, 0, 10)
|
||||
}
|
||||
viewList.add(view)
|
||||
fun injectView() {
|
||||
val viewList = mutableListOf<View>()
|
||||
friendFeedInfoMenu.inject(injectedLayout) { view ->
|
||||
view.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, 10, 0, 10)
|
||||
}
|
||||
viewList.reversed().forEach { injectedLayout.addView(it, 0) }
|
||||
viewList.add(view)
|
||||
}
|
||||
viewList.reversed().forEach { injectedLayout.addView(it, 0) }
|
||||
}
|
||||
|
||||
param.setArg(0, injectedLayout)
|
||||
@ -127,20 +122,12 @@ class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadPar
|
||||
})
|
||||
return@hook
|
||||
}
|
||||
if (messaging.lastFetchConversationUserUUID == null) return@hook
|
||||
if (messaging.lastFetchConversationUUID == null || messaging.lastFetchConversationUserUUID == null) return@hook
|
||||
|
||||
//filter by the slot index
|
||||
if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook
|
||||
friendFeedInfoMenu.inject(viewGroup, originalAddView)
|
||||
|
||||
viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {}
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
messaging.lastFetchConversationUserUUID = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||
package me.rhunk.snapenhance.ui.menu.impl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Gravity
|
||||
@ -10,8 +10,8 @@ import android.widget.ScrollView
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.Logger
|
||||
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme
|
||||
import me.rhunk.snapenhance.ui.menu.AbstractMenu
|
||||
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper.applyTheme
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
class OperaContextActionMenu : AbstractMenu() {
|
@ -1,4 +1,4 @@
|
||||
package me.rhunk.snapenhance.features.impl.ui.menus.impl
|
||||
package me.rhunk.snapenhance.ui.menu.impl
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
@ -19,8 +19,8 @@ import me.rhunk.snapenhance.config.impl.ConfigStateListValue
|
||||
import me.rhunk.snapenhance.config.impl.ConfigStateSelection
|
||||
import me.rhunk.snapenhance.config.impl.ConfigStateValue
|
||||
import me.rhunk.snapenhance.config.impl.ConfigStringValue
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
|
||||
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper
|
||||
import me.rhunk.snapenhance.ui.menu.AbstractMenu
|
||||
import me.rhunk.snapenhance.ui.menu.ViewAppearanceHelper
|
||||
|
||||
class SettingsMenu : AbstractMenu() {
|
||||
@SuppressLint("ClickableViewAccessibility")
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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(", ")})")
|
||||
}
|
||||
}
|
||||
}
|
@ -2,9 +2,8 @@ package me.rhunk.snapenhance.util.download
|
||||
|
||||
import me.rhunk.snapenhance.Logger
|
||||
import me.rhunk.snapenhance.Logger.debug
|
||||
import me.rhunk.snapenhance.ModContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.ServerSocket
|
||||
@ -13,38 +12,23 @@ import java.util.Locale
|
||||
import java.util.StringTokenizer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.function.Consumer
|
||||
|
||||
class DownloadServer(
|
||||
private val context: ModContext
|
||||
) {
|
||||
class DownloadServer {
|
||||
private val port = ThreadLocalRandom.current().nextInt(10000, 65535)
|
||||
|
||||
private val cachedData = ConcurrentHashMap<String, ByteArray>()
|
||||
private val cachedData = ConcurrentHashMap<String, InputStream>()
|
||||
private var serverSocket: ServerSocket? = null
|
||||
|
||||
fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) {
|
||||
val httpKey = java.lang.Long.toHexString(System.nanoTime())
|
||||
ensureServerStarted {
|
||||
putDownloadableContent(httpKey, content)
|
||||
val url = "http://127.0.0.1:$port/$httpKey"
|
||||
context.executeAsync {
|
||||
val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath)
|
||||
callback.accept(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureServerStarted(callback: Runnable) {
|
||||
fun ensureServerStarted(callback: DownloadServer.() -> Unit) {
|
||||
if (serverSocket != null && !serverSocket!!.isClosed) {
|
||||
callback.run()
|
||||
callback(this)
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
try {
|
||||
debug("started web server on 127.0.0.1:$port")
|
||||
serverSocket = ServerSocket(port)
|
||||
callback.run()
|
||||
callback(this)
|
||||
while (!serverSocket!!.isClosed) {
|
||||
try {
|
||||
val socket = serverSocket!!.accept()
|
||||
@ -59,8 +43,10 @@ class DownloadServer(
|
||||
}.start()
|
||||
}
|
||||
|
||||
fun putDownloadableContent(key: String, data: ByteArray) {
|
||||
cachedData[key] = data
|
||||
fun putDownloadableContent(inputStream: InputStream): String {
|
||||
val key = System.nanoTime().toString(16)
|
||||
cachedData[key] = inputStream
|
||||
return "http://127.0.0.1:$port/$key"
|
||||
}
|
||||
|
||||
private fun handleRequest(socket: Socket) {
|
||||
@ -68,49 +54,58 @@ class DownloadServer(
|
||||
val outputStream = socket.getOutputStream()
|
||||
val writer = PrintWriter(outputStream)
|
||||
val line = reader.readLine() ?: return
|
||||
val close = Runnable {
|
||||
try {
|
||||
fun close() {
|
||||
runCatching {
|
||||
reader.close()
|
||||
writer.close()
|
||||
outputStream.close()
|
||||
socket.close()
|
||||
} catch (e: Throwable) {
|
||||
Logger.xposedLog(e)
|
||||
}.onFailure {
|
||||
Logger.error("failed to close socket", it)
|
||||
}
|
||||
}
|
||||
val parse = StringTokenizer(line)
|
||||
val method = parse.nextToken().uppercase(Locale.getDefault())
|
||||
var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
|
||||
if (method != "GET") {
|
||||
writer.println("HTTP/1.1 501 Not Implemented")
|
||||
writer.println("Content-type: " + "application/octet-stream")
|
||||
writer.println("Content-length: " + 0)
|
||||
writer.println()
|
||||
writer.flush()
|
||||
close.run()
|
||||
with(writer) {
|
||||
println("HTTP/1.1 501 Not Implemented")
|
||||
println("Content-type: " + "application/octet-stream")
|
||||
println("Content-length: " + 0)
|
||||
println()
|
||||
flush()
|
||||
}
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (fileRequested.startsWith("/")) {
|
||||
fileRequested = fileRequested.substring(1)
|
||||
}
|
||||
if (!cachedData.containsKey(fileRequested)) {
|
||||
writer.println("HTTP/1.1 404 Not Found")
|
||||
writer.println("Content-type: " + "application/octet-stream")
|
||||
writer.println("Content-length: " + 0)
|
||||
writer.println()
|
||||
writer.flush()
|
||||
close.run()
|
||||
with(writer) {
|
||||
println("HTTP/1.1 404 Not Found")
|
||||
println("Content-type: " + "application/octet-stream")
|
||||
println("Content-length: " + 0)
|
||||
println()
|
||||
flush()
|
||||
}
|
||||
close()
|
||||
return
|
||||
}
|
||||
val data = cachedData[fileRequested]!!
|
||||
writer.println("HTTP/1.1 200 OK")
|
||||
writer.println("Content-type: " + "application/octet-stream")
|
||||
writer.println("Content-length: " + data.size)
|
||||
writer.println()
|
||||
writer.flush()
|
||||
outputStream.write(data, 0, data.size)
|
||||
val requestedData = cachedData[fileRequested]!!
|
||||
with(writer) {
|
||||
println("HTTP/1.1 200 OK")
|
||||
println("Content-type: " + "application/octet-stream")
|
||||
println()
|
||||
flush()
|
||||
}
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
while (requestedData.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
outputStream.flush()
|
||||
close.run()
|
||||
cachedData.remove(fileRequested)
|
||||
close()
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
package me.rhunk.snapenhance.util.snap
|
||||
|
4
app/src/main/res/drawable/action_button_cancel.xml
Normal file
4
app/src/main/res/drawable/action_button_cancel.xml
Normal 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>
|
8
app/src/main/res/drawable/action_button_success.xml
Normal file
8
app/src/main/res/drawable/action_button_success.xml
Normal 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>
|
@ -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>
|
0
app/src/main/res/font/avenir_next_medium.ttf
Normal file
0
app/src/main/res/font/avenir_next_medium.ttf
Normal file
60
app/src/main/res/layout/download_manager_activity.xml
Normal file
60
app/src/main/res/layout/download_manager_activity.xml
Normal 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>
|
70
app/src/main/res/layout/download_manager_item.xml
Normal file
70
app/src/main/res/layout/download_manager_item.xml
Normal 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>
|
12
app/src/main/res/values/colors.xml
Normal file
12
app/src/main/res/values/colors.xml
Normal 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>
|
4
app/src/main/res/values/dimens.xml
Normal file
4
app/src/main/res/values/dimens.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="download_manager_item_preview_radius">10dp</dimen>
|
||||
</resources>
|
6
app/src/main/res/values/themes.xml
Normal file
6
app/src/main/res/values/themes.xml
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user