fix(media_downloader): story voice note reply

- refactor media author and download source
- optimize download section
This commit is contained in:
rhunk 2023-09-01 11:50:42 +02:00
parent ea6260463c
commit 5776d44111
15 changed files with 170 additions and 139 deletions

View File

@ -180,7 +180,7 @@ class LogManager(
fun error(message: Any?, throwable: Throwable, tag: String = TAG) { fun error(message: Any?, throwable: Throwable, tag: String = TAG) {
internalLog(tag, LogLevel.ERROR, message) internalLog(tag, LogLevel.ERROR, message)
internalLog(tag, LogLevel.ERROR, throwable) internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString())
} }
fun info(message: Any?, tag: String = TAG) { fun info(message: Any?, tag: String = TAG) {

View File

@ -20,7 +20,7 @@ import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.core.config.ModConfig import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.core.download.DownloadTaskManager import me.rhunk.snapenhance.download.DownloadTaskManager
import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.messaging.StreaksReminder
import me.rhunk.snapenhance.ui.manager.MainActivity import me.rhunk.snapenhance.ui.manager.MainActivity

View File

@ -1,17 +1,19 @@
package me.rhunk.snapenhance.core.download.data package me.rhunk.snapenhance.download
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.rhunk.snapenhance.core.download.DownloadTaskManager import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.DownloadStage
data class DownloadObject( data class DownloadObject(
var downloadId: Int = 0, var downloadId: Int = 0,
var outputFile: String? = null, var outputFile: String? = null,
val metadata : DownloadMetadata val metadata : DownloadMetadata
) { ) {
lateinit var downloadTaskManager: DownloadTaskManager
var job: Job? = null var job: Job? = null
var changeListener = { _: DownloadStage, _: DownloadStage -> } var changeListener = { _: DownloadStage, _: DownloadStage -> }
lateinit var updateTaskCallback: (DownloadObject) -> Unit
private var _stage: DownloadStage = DownloadStage.PENDING private var _stage: DownloadStage = DownloadStage.PENDING
var downloadStage: DownloadStage var downloadStage: DownloadStage
get() = synchronized(this) { get() = synchronized(this) {
@ -20,7 +22,7 @@ data class DownloadObject(
set(value) = synchronized(this) { set(value) = synchronized(this) {
changeListener(_stage, value) changeListener(_stage, value)
_stage = value _stage = value
downloadTaskManager.updateTask(this) updateTaskCallback(this)
} }
fun isJobActive() = job?.isActive == true fun isJobActive() = job?.isActive == true

View File

@ -16,14 +16,12 @@ import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.core.download.DownloadManagerClient import me.rhunk.snapenhance.core.download.DownloadManagerClient
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMediaType
import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.DownloadObject
import me.rhunk.snapenhance.core.download.data.DownloadRequest import me.rhunk.snapenhance.core.download.data.DownloadRequest
import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.DownloadStage
import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.InputMedia
@ -320,7 +318,11 @@ class DownloadProcessor (
val downloadObjectObject = DownloadObject( val downloadObjectObject = DownloadObject(
metadata = downloadMetadata metadata = downloadMetadata
).apply { downloadTaskManager = remoteSideContext.downloadTaskManager } ).apply {
updateTaskCallback = {
remoteSideContext.downloadTaskManager.updateTask(it)
}
}
downloadObjectObject.also { downloadObjectObject.also {
remoteSideContext.downloadTaskManager.addTask(it) remoteSideContext.downloadTaskManager.addTask(it)

View File

@ -1,12 +1,11 @@
package me.rhunk.snapenhance.core.download package me.rhunk.snapenhance.download
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.DownloadObject
import me.rhunk.snapenhance.core.download.data.DownloadStage import me.rhunk.snapenhance.core.download.data.DownloadStage
import me.rhunk.snapenhance.core.download.data.MediaFilter import me.rhunk.snapenhance.core.download.data.MediaDownloadSource
import me.rhunk.snapenhance.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.util.ktx.getIntOrNull import me.rhunk.snapenhance.util.ktx.getIntOrNull
import me.rhunk.snapenhance.util.ktx.getStringOrNull import me.rhunk.snapenhance.util.ktx.getStringOrNull
@ -26,8 +25,8 @@ class DownloadTaskManager {
"hash VARCHAR UNIQUE", "hash VARCHAR UNIQUE",
"outputPath TEXT", "outputPath TEXT",
"outputFile TEXT", "outputFile TEXT",
"mediaDisplayType TEXT", "mediaAuthor TEXT",
"mediaDisplaySource TEXT", "downloadSource TEXT",
"iconUrl TEXT", "iconUrl TEXT",
"downloadStage TEXT" "downloadStage TEXT"
) )
@ -36,13 +35,13 @@ class DownloadTaskManager {
} }
fun addTask(task: DownloadObject): Int { fun addTask(task: DownloadObject): Int {
taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, mediaDisplayType, mediaDisplaySource, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)", taskDatabase.execSQL("INSERT INTO tasks (hash, outputPath, outputFile, downloadSource, mediaAuthor, iconUrl, downloadStage) VALUES (?, ?, ?, ?, ?, ?, ?)",
arrayOf( arrayOf(
task.metadata.mediaIdentifier, task.metadata.mediaIdentifier,
task.metadata.outputPath, task.metadata.outputPath,
task.outputFile, task.outputFile,
task.metadata.mediaDisplayType, task.metadata.downloadSource,
task.metadata.mediaDisplaySource, task.metadata.mediaAuthor,
task.metadata.iconUrl, task.metadata.iconUrl,
task.downloadStage.name task.downloadStage.name
) )
@ -56,13 +55,13 @@ class DownloadTaskManager {
} }
fun updateTask(task: DownloadObject) { fun updateTask(task: DownloadObject) {
taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, mediaDisplayType = ?, mediaDisplaySource = ?, iconUrl = ?, downloadStage = ? WHERE id = ?", taskDatabase.execSQL("UPDATE tasks SET hash = ?, outputPath = ?, outputFile = ?, downloadSource = ?, mediaAuthor = ?, iconUrl = ?, downloadStage = ? WHERE id = ?",
arrayOf( arrayOf(
task.metadata.mediaIdentifier, task.metadata.mediaIdentifier,
task.metadata.outputPath, task.metadata.outputPath,
task.outputFile, task.outputFile,
task.metadata.mediaDisplayType, task.metadata.downloadSource,
task.metadata.mediaDisplaySource, task.metadata.mediaAuthor,
task.metadata.iconUrl, task.metadata.iconUrl,
task.downloadStage.name, task.downloadStage.name,
task.downloadId task.downloadId
@ -113,11 +112,11 @@ class DownloadTaskManager {
removeTask(task.downloadId) removeTask(task.downloadId)
} }
fun queryFirstTasks(filter: MediaFilter): Map<Int, DownloadObject> { fun queryFirstTasks(filter: MediaDownloadSource): Map<Int, DownloadObject> {
val isPendingFilter = filter == MediaFilter.PENDING val isPendingFilter = filter == MediaDownloadSource.PENDING
val tasks = mutableMapOf<Int, DownloadObject>() val tasks = mutableMapOf<Int, DownloadObject>()
tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.mediaDisplayType) }) tasks.putAll(pendingTasks.filter { isPendingFilter || filter.matches(it.value.metadata.downloadSource) })
if (isPendingFilter) { if (isPendingFilter) {
return tasks.toSortedMap(reverseOrder()) return tasks.toSortedMap(reverseOrder())
} }
@ -132,16 +131,16 @@ class DownloadTaskManager {
} }
@SuppressLint("Range") @SuppressLint("Range")
fun queryTasks(from: Int, amount: Int = 30, filter: MediaFilter = MediaFilter.NONE): Map<Int, DownloadObject> { fun queryTasks(from: Int, amount: Int = 30, filter: MediaDownloadSource = MediaDownloadSource.NONE): Map<Int, DownloadObject> {
if (filter == MediaFilter.PENDING) { if (filter == MediaDownloadSource.PENDING) {
return emptyMap() return emptyMap()
} }
val cursor = taskDatabase.rawQuery( val cursor = taskDatabase.rawQuery(
"SELECT * FROM tasks WHERE id < ? AND mediaDisplayType LIKE ? ORDER BY id DESC LIMIT ?", "SELECT * FROM tasks WHERE id < ? AND downloadSource LIKE ? ORDER BY id DESC LIMIT ?",
arrayOf( arrayOf(
from.toString(), from.toString(),
if (filter.shouldIgnoreFilter) "%" else "%${filter.key}", if (filter.ignoreFilter) "%" else "%${filter.key}",
amount.toString() amount.toString()
) )
) )
@ -155,12 +154,12 @@ class DownloadTaskManager {
metadata = DownloadMetadata( metadata = DownloadMetadata(
outputPath = cursor.getStringOrNull("outputPath")!!, outputPath = cursor.getStringOrNull("outputPath")!!,
mediaIdentifier = cursor.getStringOrNull("hash"), mediaIdentifier = cursor.getStringOrNull("hash"),
mediaDisplayType = cursor.getStringOrNull("mediaDisplayType"), downloadSource = cursor.getStringOrNull("downloadSource") ?: MediaDownloadSource.NONE.key,
mediaDisplaySource = cursor.getStringOrNull("mediaDisplaySource"), mediaAuthor = cursor.getStringOrNull("mediaAuthor"),
iconUrl = cursor.getStringOrNull("iconUrl") iconUrl = cursor.getStringOrNull("iconUrl")
) )
).apply { ).apply {
downloadTaskManager = this@DownloadTaskManager updateTaskCallback = { updateTask(it) }
downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!) downloadStage = DownloadStage.valueOf(cursor.getStringOrNull("downloadStage")!!)
//if downloadStage is not saved, it means the app was killed before the download was finished //if downloadStage is not saved, it means the app was killed before the download was finished
if (downloadStage != DownloadStage.SAVED) { if (downloadStage != DownloadStage.SAVED) {

View File

@ -42,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -49,27 +50,42 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.asCompletableFuture
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.rhunk.snapenhance.core.download.data.MediaDownloadSource
import me.rhunk.snapenhance.data.FileType import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.core.download.data.DownloadObject import me.rhunk.snapenhance.download.DownloadObject
import me.rhunk.snapenhance.core.download.data.MediaFilter
import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.ui.util.ImageRequestHelper import me.rhunk.snapenhance.ui.util.ImageRequestHelper
class DownloadsSection : Section() { class DownloadsSection : Section() {
private val loadedDownloads = mutableStateOf(mapOf<Int, DownloadObject>()) private val loadedDownloads = mutableStateOf(mapOf<Int, DownloadObject>())
private var currentFilter = mutableStateOf(MediaFilter.NONE) private var currentFilter = mutableStateOf(MediaDownloadSource.NONE)
private val coroutineScope = CoroutineScope(Dispatchers.IO)
override fun onResumed() { override fun onResumed() {
super.onResumed() super.onResumed()
loadByFilter(currentFilter.value) coroutineScope.launch {
loadByFilter(currentFilter.value)
}
} }
private fun loadByFilter(filter: MediaFilter) { private fun loadByFilter(filter: MediaDownloadSource) {
this.currentFilter.value = filter this.currentFilter.value = filter
synchronized(loadedDownloads) { synchronized(loadedDownloads) {
loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter) loadedDownloads.value = context.downloadTaskManager.queryFirstTasks(filter).toMutableMap()
}
}
private fun removeTask(download: DownloadObject) {
synchronized(loadedDownloads) {
loadedDownloads.value = loadedDownloads.value.toMutableMap().also {
it.remove(download.downloadId)
}
context.downloadTaskManager.removeTask(download)
} }
} }
@ -87,7 +103,6 @@ class DownloadsSection : Section() {
@Composable @Composable
private fun FilterList() { private fun FilterList() {
val coroutineScope = rememberCoroutineScope()
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu}) { IconButton(onClick = { showMenu = !showMenu}) {
Icon( Icon(
@ -97,7 +112,7 @@ class DownloadsSection : Section() {
} }
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
MediaFilter.values().toList().forEach { filter -> MediaDownloadSource.values().toList().forEach { filter ->
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row( Row(
@ -110,7 +125,7 @@ class DownloadsSection : Section() {
selected = (currentFilter.value == filter), selected = (currentFilter.value == filter),
onClick = null onClick = null
) )
Text(filter.name, modifier = Modifier.weight(1f)) Text(filter.displayName, modifier = Modifier.weight(1f))
} }
}, },
onClick = { onClick = {
@ -144,11 +159,12 @@ class DownloadsSection : Section() {
context.androidContext, context.androidContext,
download.outputFile download.outputFile
), ),
imageLoader = context.imageLoader imageLoader = context.imageLoader,
filterQuality = FilterQuality.None,
), ),
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.blur(12.dp), .blur(5.dp),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
@ -156,9 +172,9 @@ class DownloadsSection : Section() {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(start = 10.dp, end = 10.dp) .padding(start = 10.dp, end = 10.dp)
.fillMaxWidth()
.fillMaxHeight(), .fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
){ ){
//info card //info card
Row( Row(
@ -177,13 +193,13 @@ class DownloadsSection : Section() {
verticalArrangement = Arrangement.SpaceBetween verticalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = download.metadata.mediaDisplayType ?: "", text = MediaDownloadSource.fromKey(download.metadata.downloadSource).displayName,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Text( Text(
text = download.metadata.mediaDisplaySource ?: "", text = download.metadata.mediaAuthor ?: "",
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light fontWeight = FontWeight.Light
@ -191,16 +207,17 @@ class DownloadsSection : Section() {
} }
} }
Spacer(modifier = Modifier.weight(1f))
//action buttons //action buttons
Row( Row(
modifier = Modifier modifier = Modifier
.padding(5.dp), .padding(5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
FilledIconButton( FilledIconButton(
onClick = { onClick = {
removeTask(download)
}, },
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
@ -240,6 +257,7 @@ class DownloadsSection : Section() {
@Composable @Composable
override fun Content() { override fun Content() {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn( LazyColumn(
state = scrollState, state = scrollState,
@ -252,14 +270,19 @@ class DownloadsSection : Section() {
item { item {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
if (loadedDownloads.value.isEmpty()) { if (loadedDownloads.value.isEmpty()) {
Text(text = "No downloads", fontSize = 20.sp, modifier = Modifier Text(text = "(empty)", fontSize = 20.sp, modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(10.dp), textAlign = TextAlign.Center) .padding(10.dp), textAlign = TextAlign.Center)
} }
LaunchedEffect(true) { LaunchedEffect(Unit) {
val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect val lastItemIndex = (loadedDownloads.value.size - 1).takeIf { it >= 0 } ?: return@LaunchedEffect
lazyLoadFromIndex(lastItemIndex) scope.launch(Dispatchers.IO) {
scrollState.animateScrollToItem(lastItemIndex) lazyLoadFromIndex(lastItemIndex)
}.asCompletableFuture().thenAccept {
scope.launch {
scrollState.animateScrollToItem(lastItemIndex)
}
}
} }
} }
} }

View File

@ -53,6 +53,8 @@ object ImageRequestHelper {
fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context) fun newDownloadPreviewImageRequest(context: Context, filePath: String?) = ImageRequest.Builder(context)
.data(filePath) .data(filePath)
.cacheKey(filePath) .cacheKey(filePath)
.memoryCacheKey(filePath)
.crossfade(true) .crossfade(true)
.crossfade(200)
.build() .build()
} }

View File

@ -388,11 +388,12 @@
"conversation_info": "\uD83D\uDC64 Conversation Info" "conversation_info": "\uD83D\uDC64 Conversation Info"
}, },
"path_format": { "path_format": {
"create_user_folder": "Create folder for each user", "create_author_folder": "Create folder for each author",
"create_source_folder": "Create folder for each media source type",
"append_hash": "Add a unique hash to the file name", "append_hash": "Add a unique hash to the file name",
"append_source": "Add the media source to the file name",
"append_username": "Add the username to the file name", "append_username": "Add the username to the file name",
"append_date_time": "Add the date and time to the file name", "append_date_time": "Add the date and time to the file name"
"append_type": "Add the media type to the file name"
}, },
"auto_download_sources": { "auto_download_sources": {
"friend_snaps": "Friend Snaps", "friend_snaps": "Friend Snaps",

View File

@ -14,11 +14,12 @@ class DownloaderConfig : ConfigContainer() {
) )
val preventSelfAutoDownload = boolean("prevent_self_auto_download") val preventSelfAutoDownload = boolean("prevent_self_auto_download")
val pathFormat = multiple("path_format", val pathFormat = multiple("path_format",
"create_user_folder", "create_author_folder",
"create_source_folder",
"append_hash", "append_hash",
"append_source",
"append_username",
"append_date_time", "append_date_time",
"append_type",
"append_username"
).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) } ).apply { set(mutableListOf("append_hash", "append_date_time", "append_type", "append_username")) }
val allowDuplicate = boolean("allow_duplicate") val allowDuplicate = boolean("allow_duplicate")
val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) } val mergeOverlays = boolean("merge_overlays") { addNotices(FeatureNotice.MAY_CAUSE_CRASHES) }

View File

@ -3,7 +3,7 @@ package me.rhunk.snapenhance.core.download.data
data class DownloadMetadata( data class DownloadMetadata(
val mediaIdentifier: String?, val mediaIdentifier: String?,
val outputPath: String, val outputPath: String,
val mediaDisplaySource: String?, val mediaAuthor: String?,
val mediaDisplayType: String?, val downloadSource: String,
val iconUrl: String? val iconUrl: String?
) )

View File

@ -0,0 +1,28 @@
package me.rhunk.snapenhance.core.download.data
enum class MediaDownloadSource(
val key: String,
val displayName: String = key,
val pathName: String = key,
val ignoreFilter: Boolean = false
) {
NONE("none", "None", ignoreFilter = true),
PENDING("pending", "Pending", ignoreFilter = true),
CHAT_MEDIA("chat_media", "Chat Media", "chat_media"),
STORY("story", "Story", "story"),
PUBLIC_STORY("public_story", "Public Story", "public_story"),
SPOTLIGHT("spotlight", "Spotlight", "spotlight"),
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture");
fun matches(source: String?): Boolean {
if (source == null) return false
return source.contains(key, ignoreCase = true)
}
companion object {
fun fromKey(key: String?): MediaDownloadSource {
if (key == null) return NONE
return values().find { it.key == key } ?: NONE
}
}
}

View File

@ -1,18 +0,0 @@
package me.rhunk.snapenhance.core.download.data
enum class MediaFilter(
val key: String,
val shouldIgnoreFilter: Boolean = false
) {
NONE("none", true),
PENDING("pending", true),
CHAT_MEDIA("chat_media"),
STORY("story"),
SPOTLIGHT("spotlight"),
PROFILE_PICTURE("profile_picture");
fun matches(source: String?): Boolean {
if (source == null) return false
return source.contains(key, ignoreCase = true)
}
}

View File

@ -12,7 +12,7 @@ import me.rhunk.snapenhance.core.download.DownloadManagerClient
import me.rhunk.snapenhance.core.download.data.DownloadMediaType import me.rhunk.snapenhance.core.download.data.DownloadMediaType
import me.rhunk.snapenhance.core.download.data.DownloadMetadata import me.rhunk.snapenhance.core.download.data.DownloadMetadata
import me.rhunk.snapenhance.core.download.data.InputMedia import me.rhunk.snapenhance.core.download.data.InputMedia
import me.rhunk.snapenhance.core.download.data.MediaFilter import me.rhunk.snapenhance.core.download.data.MediaDownloadSource
import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType
import me.rhunk.snapenhance.core.download.data.toKeyPair import me.rhunk.snapenhance.core.download.data.toKeyPair
import me.rhunk.snapenhance.core.messaging.MessagingRuleType import me.rhunk.snapenhance.core.messaging.MessagingRuleType
@ -45,16 +45,20 @@ import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private fun String.sanitizeForPath(): String {
return this.replace(" ", "_")
.replace(Regex("\\p{Cntrl}"), "")
}
@OptIn(ExperimentalEncodingApi::class) @OptIn(ExperimentalEncodingApi::class)
class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD, loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null private var lastSeenMapParams: ParamMap? = null
private fun provideDownloadManagerClient( private fun provideDownloadManagerClient(
pathSuffix: String,
mediaIdentifier: String, mediaIdentifier: String,
mediaDisplaySource: String? = null, mediaAuthor: String,
mediaDisplayType: String? = null, downloadSource: MediaDownloadSource,
friendInfo: FriendInfo? = null friendInfo: FriendInfo? = null
): DownloadManagerClient { ): DownloadManagerClient {
val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "") val generatedHash = mediaIdentifier.hashCode().toString(16).replaceFirst("-", "")
@ -66,7 +70,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
context.shortToast(context.translation["download_processor.download_started_toast"]) context.shortToast(context.translation["download_processor.download_started_toast"])
} }
val outputPath = createNewFilePath(generatedHash, mediaDisplayType, pathSuffix) val outputPath = createNewFilePath(generatedHash, downloadSource, mediaAuthor)
return DownloadManagerClient( return DownloadManagerClient(
context = context, context = context,
@ -74,8 +78,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) { mediaIdentifier = if (!context.config.downloader.allowDuplicate.get()) {
generatedHash generatedHash
} else null, } else null,
mediaDisplaySource = mediaDisplaySource, mediaAuthor = mediaAuthor,
mediaDisplayType = mediaDisplayType, downloadSource = downloadSource.key,
iconUrl = iconUrl, iconUrl = iconUrl,
outputPath = outputPath outputPath = outputPath
), ),
@ -106,13 +110,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
} }
//TODO: implement subfolder argument private fun createNewFilePath(hexHash: String, downloadSource: MediaDownloadSource, mediaAuthor: String): String {
private fun createNewFilePath(hexHash: String, mediaDisplayType: String?, pathPrefix: String): String {
val pathFormat by context.config.downloader.pathFormat val pathFormat by context.config.downloader.pathFormat
val sanitizedPathPrefix = pathPrefix val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
.replace(" ", "_")
.replace(Regex("[\\p{Cntrl}]"), "")
.ifEmpty { hexHash }
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis()) val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(System.currentTimeMillis())
@ -126,19 +126,20 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
} }
} }
if (pathFormat.contains("create_user_folder")) { if (pathFormat.contains("create_author_folder")) {
finalPath.append(sanitizedPathPrefix).append("/") finalPath.append(sanitizedMediaAuthor).append("/")
}
if (pathFormat.contains("create_source_folder")) {
finalPath.append(downloadSource.pathName).append("/")
} }
if (pathFormat.contains("append_hash")) { if (pathFormat.contains("append_hash")) {
appendFileName(hexHash) appendFileName(hexHash)
} }
mediaDisplayType?.let { if (pathFormat.contains("append_source")) {
if (pathFormat.contains("append_type")) { appendFileName(downloadSource.pathName)
appendFileName(it.lowercase().replace(" ", "-"))
}
} }
if (pathFormat.contains("append_username")) { if (pathFormat.contains("append_username")) {
appendFileName(sanitizedPathPrefix) appendFileName(sanitizedMediaAuthor)
} }
if (pathFormat.contains("append_date_time")) { if (pathFormat.contains("append_date_time")) {
appendFileName(currentDateTime) appendFileName(currentDateTime)
@ -235,10 +236,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val authorUsername = author.usernameForSorting!! val authorUsername = author.usernameForSorting!!
downloadOperaMedia(provideDownloadManagerClient( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorUsername,
mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}", mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}",
mediaDisplaySource = authorUsername, mediaAuthor = authorUsername,
mediaDisplayType = MediaFilter.CHAT_MEDIA.key, downloadSource = MediaDownloadSource.CHAT_MEDIA,
friendInfo = author friendInfo = author
), mediaInfoMap) ), mediaInfoMap)
@ -278,10 +278,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!forceDownload && !canUseRule(author.userId!!)) return if (!forceDownload && !canUseRule(author.userId!!)) return
downloadOperaMedia(provideDownloadManagerClient( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = authorName,
mediaIdentifier = paramMap["MEDIA_ID"].toString(), mediaIdentifier = paramMap["MEDIA_ID"].toString(),
mediaDisplaySource = authorName, mediaAuthor = authorName,
mediaDisplayType = MediaFilter.STORY.key, downloadSource = MediaDownloadSource.STORY,
friendInfo = author friendInfo = author
), mediaInfoMap) ), mediaInfoMap)
return return
@ -292,15 +291,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//public stories //public stories
if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") && if ((snapSource == "PUBLIC_USER" || snapSource == "SAVED_STORY") &&
(forceDownload || canAutoDownload("public_stories"))) { (forceDownload || canAutoDownload("public_stories"))) {
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 "").sanitizeForPath()
"[\\p{Cntrl}]".toRegex(),
"")
downloadOperaMedia(provideDownloadManagerClient( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Public-Stories/$userDisplayName",
mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = userDisplayName, mediaAuthor = userDisplayName,
mediaDisplaySource = "Public Story" downloadSource = MediaDownloadSource.PUBLIC_STORY,
), mediaInfoMap) ), mediaInfoMap)
return return
} }
@ -308,10 +304,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//spotlight //spotlight
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) { if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || canAutoDownload("spotlight"))) {
downloadOperaMedia(provideDownloadManagerClient( downloadOperaMedia(provideDownloadManagerClient(
pathSuffix = "Spotlight",
mediaIdentifier = paramMap["SNAP_ID"].toString(), mediaIdentifier = paramMap["SNAP_ID"].toString(),
mediaDisplayType = MediaFilter.SPOTLIGHT.key, downloadSource = MediaDownloadSource.SPOTLIGHT,
mediaDisplaySource = paramMap["TIME_STAMP"].toString() mediaAuthor = paramMap["TIME_STAMP"].toString()
), mediaInfoMap) ), mediaInfoMap)
return return
} }
@ -319,9 +314,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
//stories with mpeg dash media //stories with mpeg dash media
//TODO: option to download multiple chapters //TODO: option to download multiple chapters
if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) {
val storyName = paramMap["STORY_NAME"].toString().replace( val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath()
"[\\p{Cntrl}]".toRegex(),
"")
//get the position of the media in the playlist and the duration //get the position of the media in the playlist and the duration
val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!)
@ -338,20 +331,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp) val duration: Long? = nextChapter?.startTimeMs?.minus(snapChapterTimestamp)
//get the mpd playlist and append the cdn url to baseurl nodes //get the mpd playlist and append the cdn url to baseurl nodes
context.log.verbose("Downloading dash media ${paramMap["MEDIA_ID"].toString()}", featureKey)
val playlistUrl = paramMap["MEDIA_ID"].toString().let { val playlistUrl = paramMap["MEDIA_ID"].toString().let {
val urlIndex = it.indexOf("https://cf-st.sc-cdn.net") val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net"))
if (urlIndex == -1) {
"${RemoteMediaResolver.CF_ST_CDN_D}$it" urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex ->
} else { it.substring(validIndex)
it.substring(urlIndex) } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it"
}
} }
provideDownloadManagerClient( provideDownloadManagerClient(
pathSuffix = "Pro-Stories/${storyName}",
mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}", mediaIdentifier = "${paramMap["STORY_ID"]}-${snapItem.snapId}",
mediaDisplaySource = storyName, downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaDisplayType = "Pro Story" mediaAuthor = storyName
).downloadDashMedia( ).downloadDashMedia(
playlistUrl, playlistUrl,
snapChapterTimestamp, snapChapterTimestamp,
@ -476,10 +468,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
if (!isPreview) { if (!isPreview) {
val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage) val encryptionKeys = EncryptionHelper.getEncryptionKeys(contentType, messageReader, isArroyo = isArroyoMessage)
provideDownloadManagerClient( provideDownloadManagerClient(
pathSuffix = authorName,
mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}", mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}",
mediaDisplaySource = authorName, downloadSource = MediaDownloadSource.CHAT_MEDIA,
mediaDisplayType = MediaFilter.CHAT_MEDIA.key, mediaAuthor = authorName,
friendInfo = friendInfo friendInfo = friendInfo
).downloadSingleMedia( ).downloadSingleMedia(
Base64.UrlSafe.encode(urlProto), Base64.UrlSafe.encode(urlProto),
@ -532,10 +523,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
fun downloadProfilePicture(url: String, author: String) { fun downloadProfilePicture(url: String, author: String) {
provideDownloadManagerClient( provideDownloadManagerClient(
pathSuffix = "Profile Pictures",
mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""),
mediaDisplaySource = author, mediaAuthor = author,
mediaDisplayType = MediaFilter.PROFILE_PICTURE.key downloadSource = MediaDownloadSource.PROFILE_PICTURE
).downloadSingleMedia( ).downloadSingleMedia(
url, url,
DownloadMediaType.REMOTE_MEDIA DownloadMediaType.REMOTE_MEDIA

View File

@ -12,13 +12,13 @@ import javax.crypto.spec.SecretKeySpec
object EncryptionHelper { object EncryptionHelper {
fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? {
val messageMediaInfo = MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) ?: return null val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(messageProto, contentType, isArroyo) ?: return null
val encryptionProtoIndex = if (messageMediaInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) { val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
Constants.ENCRYPTION_PROTO_INDEX_V2 Constants.ENCRYPTION_PROTO_INDEX_V2
} else { } else {
Constants.ENCRYPTION_PROTO_INDEX Constants.ENCRYPTION_PROTO_INDEX
} }
val encryptionProto = messageMediaInfo.followPath(encryptionProtoIndex) ?: return null val encryptionProto = mediaEncryptionInfo.followPath(encryptionProtoIndex) ?: return null
var key: ByteArray = encryptionProto.getByteArray(1)!! var key: ByteArray = encryptionProto.getByteArray(1)!!
var iv: ByteArray = encryptionProto.getByteArray(2)!! var iv: ByteArray = encryptionProto.getByteArray(2)!!

View File

@ -18,7 +18,7 @@ import java.util.zip.ZipInputStream
object MediaDownloaderHelper { object MediaDownloaderHelper {
fun getMessageMediaInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? { fun getMessageMediaEncryptionInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? {
val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader
val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1) val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1)
@ -27,12 +27,13 @@ object MediaDownloaderHelper {
ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath)) ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath))
ContentType.EXTERNAL_MEDIA -> { ContentType.EXTERNAL_MEDIA -> {
val externalMediaTypes = arrayOf( val externalMediaTypes = arrayOf(
intArrayOf(3, 3), //normal external media intArrayOf(3, 3, *mediaContainerPath), //normal external media
intArrayOf(7, 12, 3), //attached story reply intArrayOf(7, 15, 1, 1), //attached audio note
intArrayOf(7, 3) //original story reply intArrayOf(7, 12, 3, *mediaContainerPath), //attached story reply
intArrayOf(7, 3, *mediaContainerPath), //original story reply
) )
externalMediaTypes.forEach { path -> externalMediaTypes.forEach { path ->
messageContainerPath.followPath(*(path + mediaContainerPath))?.also { return it } messageContainerPath.followPath(*path)?.also { return it }
} }
null null
} }