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