feat(experimental): story logger

This commit is contained in:
rhunk 2023-12-17 02:50:01 +01:00
parent 195dd278d8
commit 614d629f07
17 changed files with 523 additions and 82 deletions

View File

@ -58,6 +58,16 @@
android:exported="true" /> android:exported="true" />
<receiver android:name=".messaging.StreaksReminder" /> <receiver android:name=".messaging.StreaksReminder" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="me.rhunk.snapenhance.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -23,7 +23,6 @@ import me.rhunk.snapenhance.common.data.download.DownloadMetadata
import me.rhunk.snapenhance.common.data.download.DownloadRequest import me.rhunk.snapenhance.common.data.download.DownloadRequest
import me.rhunk.snapenhance.common.data.download.InputMedia import me.rhunk.snapenhance.common.data.download.InputMedia
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
import me.rhunk.snapenhance.task.PendingTask import me.rhunk.snapenhance.task.PendingTask
@ -35,7 +34,6 @@ import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory import javax.xml.transform.TransformerFactory
@ -44,7 +42,6 @@ import javax.xml.transform.stream.StreamResult
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.absoluteValue
data class DownloadedFile( data class DownloadedFile(
val file: File, val file: File,
@ -331,11 +328,8 @@ class DownloadProcessor (
return newFile return newFile
} }
fun onReceive(intent: Intent) { fun enqueue(downloadRequest: DownloadRequest, downloadMetadata: DownloadMetadata) {
remoteSideContext.coroutineScope.launch { remoteSideContext.coroutineScope.launch {
val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task -> remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task ->
remoteSideContext.log.debug("already queued or downloaded") remoteSideContext.log.debug("already queued or downloaded")
@ -451,4 +445,11 @@ class DownloadProcessor (
} }
} }
} }
fun onReceive(intent: Intent) {
val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
enqueue(downloadRequest, downloadMetadata)
}
} }

View File

@ -122,9 +122,11 @@ class SettingsSection(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
var storedMessagesCount by remember { mutableIntStateOf(0) } var storedMessagesCount by remember { mutableIntStateOf(0) }
var storedStoriesCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
storedMessagesCount = context.messageLogger.getStoredMessageCount() storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
} }
} }
Row( Row(
@ -134,7 +136,13 @@ class SettingsSection(
.fillMaxWidth() .fillMaxWidth()
.padding(5.dp) .padding(5.dp)
) { ) {
Text(text = "$storedMessagesCount messages", modifier = Modifier.weight(1f)) Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(text = "$storedMessagesCount messages")
Text(text = "$storedStoriesCount stories")
}
Button(onClick = { Button(onClick = {
runCatching { runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
@ -153,8 +161,9 @@ class SettingsSection(
} }
Button(onClick = { Button(onClick = {
runCatching { runCatching {
context.messageLogger.clearMessages() context.messageLogger.clearAll()
storedMessagesCount = 0 storedMessagesCount = 0
storedStoriesCount = 0
}.onFailure { }.onFailure {
context.log.error("Failed to clear messages", it) context.log.error("Failed to clear messages", it)
context.longToast("Failed to clear messages! ${it.localizedMessage}") context.longToast("Failed to clear messages! ${it.localizedMessage}")

View File

@ -0,0 +1,267 @@
package me.rhunk.snapenhance.ui.manager.sections.social
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import coil.annotation.ExperimentalCoilApi
import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
import me.rhunk.snapenhance.core.util.media.PreviewUtils
import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.ui.util.Dialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.DateFormat
import java.util.Date
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.math.absoluteValue
@OptIn(ExperimentalCoilApi::class)
@Composable
fun LoggedStories(
context: RemoteSideContext,
userId: String
) {
val stories = remember {
mutableStateListOf<StoryData>()
}
val friendInfo = remember {
context.modDatabase.getFriendInfo(userId)
}
val httpClient = remember { OkHttpClient() }
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
var coilCacheFile by remember { mutableStateOf<File?>(null) }
selectedStory?.let { story ->
Dialog(onDismissRequest = {
selectedStory = null
}) {
Card(
modifier = Modifier
.padding(4.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Posted on ${story.postedAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Text(text = "Created at ${story.createdAt.let {
DateFormat.getDateTimeInstance().format(Date(it))
}}")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = {
context.androidContext.externalCacheDir?.let { cacheDir ->
val cacheFile = coilCacheFile ?: run {
context.shortToast("Failed to get file")
return@Button
}
val targetFile = File(cacheDir, cacheFile.name)
cacheFile.copyTo(targetFile, overwrite = true)
context.androidContext.startActivity(Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(
FileProvider.getUriForFile(
context.androidContext,
"me.rhunk.snapenhance.fileprovider",
targetFile
),
FileType.fromFile(targetFile).mimeType
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}) {
Text(text = "Open")
}
Button(onClick = {
val mediaAuthor = friendInfo?.mutableUsername ?: userId
val uniqueHash = selectedStory?.url?.longHashCode()?.absoluteValue?.toString(16) ?: UUID.randomUUID().toString()
DownloadProcessor(
remoteSideContext = context,
callback = object: DownloadCallback.Default() {
override fun onSuccess(outputPath: String?) {
context.shortToast("Downloaded to $outputPath")
}
override fun onFailure(message: String?, throwable: String?) {
context.shortToast("Failed to download $message")
}
}
).enqueue(DownloadRequest(
inputMedias = arrayOf(
InputMedia(
content = story.url,
type = DownloadMediaType.REMOTE_MEDIA,
encryption = story.key?.let { it to story.iv!! }?.toKeyPair()
)
)
), DownloadMetadata(
mediaIdentifier = uniqueHash,
outputPath = createNewFilePath(
context.config.root,
uniqueHash,
MediaDownloadSource.STORY_LOGGER,
mediaAuthor,
story.createdAt
),
iconUrl = null,
mediaAuthor = friendInfo?.mutableUsername ?: userId,
downloadSource = MediaDownloadSource.STORY_LOGGER.key
))
}) {
Text(text = "Download")
}
}
}
}
}
}
LazyVerticalGrid(
columns = GridCells.Adaptive(100.dp),
contentPadding = PaddingValues(8.dp),
) {
items(stories) { story ->
var imageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
runCatching {
val mediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
snapshot.data.toFile().inputStream().use { inputStream ->
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
mediaList[type] = splitInputStream.readBytes()
}
}
val originalMedia = mediaList[SplitMediaAssetType.ORIGINAL] ?: return@runCatching false
val overlay = mediaList[SplitMediaAssetType.OVERLAY]
var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo)
overlay?.also {
bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size))
}
imageBitmap = bitmap?.asImageBitmap()
return true
}
return false
}
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
withTimeout(10000L) {
context.imageLoader.diskCache?.openSnapshot(uniqueHash)?.let {
openDiskCacheSnapshot(it)
it.close()
return@withTimeout
}
val response = httpClient.newCall(Request(
url = story.url.toHttpUrl()
)).execute()
response.body.byteStream().use {
val decrypted = story.key?.let { _ ->
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(story.key, "AES"), IvParameterSpec(story.iv))
CipherInputStream(it, cipher)
} ?: it
context.imageLoader.diskCache?.openEditor(uniqueHash)?.apply {
data.toFile().outputStream().use { fos ->
decrypted.copyTo(fos)
}
commitAndOpenSnapshot()?.use { snapshot ->
openDiskCacheSnapshot(snapshot)
snapshot.close()
}
}
}
}
}
}
Column(
modifier = Modifier
.padding(8.dp)
.clickable {
selectedStory = story
coilCacheFile = context.imageLoader.diskCache?.openSnapshot(uniqueHash).use {
it?.data?.toFile()
}
}
.heightIn(min = 128.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
imageBitmap?.let {
Card {
Image(
bitmap = it,
modifier = Modifier.fillMaxSize(),
contentDescription = null,
)
}
} ?: run {
CircularProgressIndicator()
}
}
}
item {
LaunchedEffect(Unit) {
context.messageLogger.getStories(userId, lastStoryTimestamp, 20).also { result ->
stories.addAll(result.values)
result.keys.minOrNull()?.let {
lastStoryTimestamp = it
}
}
}
}
}
}

View File

@ -4,6 +4,7 @@ import android.content.Intent
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@ -143,7 +144,7 @@ class ScopeContent(
val hours = minutes / 60 val hours = minutes / 60
val days = hours / 24 val days = hours / 24
if (days > 0) { if (days > 0) {
stringBuilder.append("$days days ") stringBuilder.append("$days day ")
return stringBuilder.toString() return stringBuilder.toString()
} }
if (hours > 0) { if (hours > 0) {
@ -201,6 +202,22 @@ class ScopeContent(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.storyLogger.get()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
) {
Button(onClick = {
navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id))
}) {
Text("Show Logged Stories")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Column { Column {
//streaks //streaks
streaks?.let { streaks?.let {
@ -241,6 +258,7 @@ class ScopeContent(
} }
} }
} }
Spacer(modifier = Modifier.height(16.dp))
// e2ee section // e2ee section
SectionTitle(translation["e2ee_title"]) SectionTitle(translation["e2ee_title"])

View File

@ -44,6 +44,7 @@ class SocialSection : Section() {
companion object { companion object {
const val MAIN_ROUTE = "social_route" const val MAIN_ROUTE = "social_route"
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}" const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}"
const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}"
} }
private var currentScopeContent: ScopeContent? = null private var currentScopeContent: ScopeContent? = null
@ -84,6 +85,11 @@ class SocialSection : Section() {
} }
} }
composable(LOGGED_STORIES_ROUTE) {
val userId = it.arguments?.getString("userId") ?: return@composable
LoggedStories(context, userId)
}
composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry -> composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
val id = navBackStackEntry.arguments?.getString("id") ?: return@composable val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="."/>
</paths>

View File

@ -21,4 +21,9 @@ interface MessageLoggerInterface {
* Delete a message from the message logger database * Delete a message from the message logger database
*/ */
void deleteMessage(String conversationId, long id); void deleteMessage(String conversationId, long id);
/**
* Add a story to the message logger database if it is not already there
*/
boolean addStory(String userId, String url, long postedAt, long createdAt, in byte[] key, in byte[] iv);
} }

View File

@ -597,6 +597,10 @@
"name": "Convert Message Locally", "name": "Convert Message Locally",
"description": "Converts snaps to chat external media locally. This appears in chat download context menu" "description": "Converts snaps to chat external media locally. This appears in chat download context menu"
}, },
"story_logger": {
"name": "Story Logger",
"description": "Provides a history of friends stories"
},
"app_passcode": { "app_passcode": {
"name": "App Passcode", "name": "App Passcode",
"description": "Sets a passcode to lock the app" "description": "Sets a passcode to lock the app"

View File

@ -4,7 +4,11 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.* import kotlinx.coroutines.*
import me.rhunk.snapenhance.bridge.MessageLoggerInterface import me.rhunk.snapenhance.bridge.MessageLoggerInterface
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@ -25,6 +29,15 @@ class MessageLoggerWrapper(
"conversation_id VARCHAR", "conversation_id VARCHAR",
"message_id BIGINT", "message_id BIGINT",
"message_data BLOB" "message_data BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
) )
)) ))
_database = openedDatabase _database = openedDatabase
@ -89,9 +102,10 @@ class MessageLoggerWrapper(
return true return true
} }
fun clearMessages() { fun clearAll() {
coroutineScope.launch { coroutineScope.launch {
database.execSQL("DELETE FROM messages") database.execSQL("DELETE FROM messages")
database.execSQL("DELETE FROM stories")
} }
} }
@ -103,9 +117,54 @@ class MessageLoggerWrapper(
return count return count
} }
fun getStoredStoriesCount(): Int {
val cursor = database.rawQuery("SELECT COUNT(*) FROM stories", null)
cursor.moveToFirst()
val count = cursor.getInt(0)
cursor.close()
return count
}
override fun deleteMessage(conversationId: String, messageId: Long) { override fun deleteMessage(conversationId: String, messageId: Long) {
coroutineScope.launch { coroutineScope.launch {
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString())) database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
} }
} }
override fun addStory(userId: String, url: String, postedAt: Long, createdAt: Long, key: ByteArray?, iv: ByteArray?): Boolean {
if (database.rawQuery("SELECT id FROM stories WHERE user_id = ? AND url = ?", arrayOf(userId, url)).use {
it.moveToFirst()
}) {
return false
}
runBlocking {
withContext(coroutineScope.coroutineContext) {
database.insert("stories", null, ContentValues().apply {
put("user_id", userId)
put("url", url)
put("posted_timestamp", postedAt)
put("created_timestamp", createdAt)
put("encryption_key", key)
put("encryption_iv", iv)
})
}
}
return true
}
fun getStories(userId: String, from: Long, limit: Int = Int.MAX_VALUE): Map<Long, StoryData> {
val stories = sortedMapOf<Long, StoryData>()
database.rawQuery("SELECT * FROM stories WHERE user_id = ? AND posted_timestamp < ? ORDER BY posted_timestamp DESC LIMIT $limit", arrayOf(userId, from.toString())).use {
while (it.moveToNext()) {
stories[it.getLongOrNull("posted_timestamp") ?: continue] = StoryData(
url = it.getStringOrNull("url") ?: continue,
postedAt = it.getLongOrNull("posted_timestamp") ?: continue,
createdAt = it.getLongOrNull("created_timestamp") ?: continue,
key = it.getBlobOrNull("encryption_key"),
iv = it.getBlobOrNull("encryption_iv")
)
}
}
return stories
}
} }

View File

@ -11,6 +11,7 @@ class Experimental : ConfigContainer() {
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() } val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() }
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() } val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() } val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }
val appPasscode = string("app_passcode") val appPasscode = string("app_passcode")
val appLockOnResume = boolean("app_lock_on_resume") val appLockOnResume = boolean("app_lock_on_resume")
val infiniteStoryBoost = boolean("infinite_story_boost") val infiniteStoryBoost = boolean("infinite_story_boost")

View File

@ -71,3 +71,12 @@ data class MessagingFriendInfo(
val bitmojiId: String?, val bitmojiId: String?,
val selfieId: String? val selfieId: String?
) : SerializableDataObject() ) : SerializableDataObject()
class StoryData(
val url: String,
val postedAt: Long,
val createdAt: Long,
val key: ByteArray?,
val iv: ByteArray?
) : SerializableDataObject()

View File

@ -1,5 +1,9 @@
package me.rhunk.snapenhance.common.data.download package me.rhunk.snapenhance.common.data.download
import me.rhunk.snapenhance.common.config.impl.RootConfig
import java.text.SimpleDateFormat
import java.util.Locale
data class DashOptions(val offsetTime: Long, val duration: Long?) data class DashOptions(val offsetTime: Long, val duration: Long?)
data class InputMedia( data class InputMedia(
@ -25,4 +29,55 @@ class DownloadRequest(
val shouldMergeOverlay: Boolean val shouldMergeOverlay: Boolean
get() = flags and Flags.MERGE_OVERLAY != 0 get() = flags and Flags.MERGE_OVERLAY != 0
}
fun String.sanitizeForPath(): String {
return this.replace(" ", "_")
.replace(Regex("\\p{Cntrl}"), "")
}
fun createNewFilePath(
config: RootConfig,
hexHash: String,
downloadSource: MediaDownloadSource,
mediaAuthor: String,
creationTimestamp: Long?
): String {
val pathFormat by config.downloader.pathFormat
val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis())
val finalPath = StringBuilder()
fun appendFileName(string: String) {
if (finalPath.isEmpty() || finalPath.endsWith("/")) {
finalPath.append(string)
} else {
finalPath.append("_").append(string)
}
}
if (pathFormat.contains("create_author_folder")) {
finalPath.append(sanitizedMediaAuthor).append("/")
}
if (pathFormat.contains("create_source_folder")) {
finalPath.append(downloadSource.pathName).append("/")
}
if (pathFormat.contains("append_hash")) {
appendFileName(hexHash)
}
if (pathFormat.contains("append_source")) {
appendFileName(downloadSource.pathName)
}
if (pathFormat.contains("append_username")) {
appendFileName(sanitizedMediaAuthor)
}
if (pathFormat.contains("append_date_time")) {
appendFileName(currentDateTime)
}
if (finalPath.isEmpty()) finalPath.append(hexHash)
return finalPath.toString()
} }

View File

@ -12,7 +12,8 @@ enum class MediaDownloadSource(
STORY("story", "Story", "story"), STORY("story", "Story", "story"),
PUBLIC_STORY("public_story", "Public Story", "public_story"), PUBLIC_STORY("public_story", "Public Story", "public_story"),
SPOTLIGHT("spotlight", "Spotlight", "spotlight"), SPOTLIGHT("spotlight", "Spotlight", "spotlight"),
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"); PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"),
STORY_LOGGER("story_logger", "Story Logger", "story_logger");
fun matches(source: String?): Boolean { fun matches(source: String?): Boolean {
if (source == null) return false if (source == null) return false

View File

@ -22,7 +22,7 @@ object MediaDownloaderHelper {
inputStream: InputStream, inputStream: InputStream,
callback: (SplitMediaAssetType, InputStream) -> Unit callback: (SplitMediaAssetType, InputStream) -> Unit
) { ) {
val bufferedInputStream = BufferedInputStream(inputStream) val bufferedInputStream = inputStream.buffered()
val fileType = getFileType(bufferedInputStream) val fileType = getFileType(bufferedInputStream)
if (fileType != FileType.ZIP) { if (fileType != FileType.ZIP) {
@ -30,16 +30,16 @@ object MediaDownloaderHelper {
return return
} }
val zipInputStream = ZipInputStream(bufferedInputStream) ZipInputStream(bufferedInputStream).use { zipInputStream ->
var entry: ZipEntry? = zipInputStream.nextEntry
var entry: ZipEntry? = zipInputStream.nextEntry while (entry != null) {
while (entry != null) { if (entry.name.startsWith("overlay")) {
if (entry.name.startsWith("overlay")) { callback(SplitMediaAssetType.OVERLAY, zipInputStream)
callback(SplitMediaAssetType.OVERLAY, zipInputStream) } else if (entry.name.startsWith("media")) {
} else if (entry.name.startsWith("media")) { callback(SplitMediaAssetType.ORIGINAL, zipInputStream)
callback(SplitMediaAssetType.ORIGINAL, zipInputStream) }
entry = zipInputStream.nextEntry
} }
entry = zipInputStream.nextEntry
} }
} }
} }

View File

@ -1,16 +1,23 @@
package me.rhunk.snapenhance.core.features.impl package me.rhunk.snapenhance.core.features.impl
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.common.data.StoryData
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams import me.rhunk.snapenhance.core.features.FeatureLoadParams
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) { class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
@OptIn(ExperimentalEncodingApi::class)
override fun init() { override fun init() {
val disablePublicStories by context.config.global.disablePublicStories val disablePublicStories by context.config.global.disablePublicStories
val storyLogger by context.config.experimental.storyLogger
context.event.subscribe(NetworkApiRequestEvent::class) { event -> context.event.subscribe(NetworkApiRequestEvent::class) { event ->
fun cancelRequest() { fun cancelRequest() {
@ -42,8 +49,8 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
} }
}.toByteArray() }.toByteArray()
} }
return@subscribe
} }
if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) { if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) {
event.onSuccess { buffer -> event.onSuccess { buffer ->
val payload = ProtoEditor(buffer ?: return@onSuccess).apply { val payload = ProtoEditor(buffer ?: return@onSuccess).apply {
@ -53,6 +60,42 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
} }
return@subscribe return@subscribe
} }
if (storyLogger && event.url.endsWith("df-mixer-prod/soma/batch_stories")) {
event.onSuccess { buffer ->
val stories = mutableMapOf<String, MutableList<StoryData>>()
val reader = ProtoReader(buffer ?: return@onSuccess)
reader.followPath(3, 3) {
eachBuffer(3) {
followPath(36) {
eachBuffer(1) data@{
val userId = getString(8, 1) ?: return@data
stories.getOrPut(userId) {
mutableListOf()
}.add(StoryData(
url = getString(2, 2)?.substringBefore("?") ?: return@data,
postedAt = getVarInt(3) ?: -1L,
createdAt = getVarInt(27) ?: -1L,
key = Base64.decode(getString(2, 5) ?: return@data),
iv = Base64.decode(getString(2, 4) ?: return@data)
))
}
}
}
}
context.coroutineScope.launch {
stories.forEach { (userId, stories) ->
stories.forEach { story ->
context.bridgeClient.getMessageLogger().addStory(userId, story.url, story.postedAt, story.createdAt, story.key, story.iv)
}
}
}
}
return@subscribe
}
} }
} }
} }

View File

@ -18,11 +18,7 @@ import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.common.data.FileType import me.rhunk.snapenhance.common.data.FileType
import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.download.DownloadMediaType import me.rhunk.snapenhance.common.data.download.*
import me.rhunk.snapenhance.common.data.download.DownloadMetadata
import me.rhunk.snapenhance.common.data.download.InputMedia
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.ConversationMessage
import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.database.impl.FriendInfo
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
@ -53,19 +49,12 @@ import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap
import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.nio.file.Paths import java.nio.file.Paths
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
private fun String.sanitizeForPath(): String {
return this.replace(" ", "_")
.replace(Regex("\\p{Cntrl}"), "")
}
class SnapChapterInfo( class SnapChapterInfo(
val offset: Long, val offset: Long,
val duration: Long? val duration: Long?
@ -100,7 +89,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
context.shortToast(translations["download_started_toast"]) context.shortToast(translations["download_started_toast"])
} }
val outputPath = createNewFilePath(generatedHash.substring(0, generatedHash.length.coerceAtMost(8)), downloadSource, mediaAuthor, creationTimestamp?.takeIf { it > 0L }) val outputPath = createNewFilePath(
context.config,
generatedHash.substring(0, generatedHash.length.coerceAtMost(8)),
downloadSource,
mediaAuthor,
creationTimestamp?.takeIf { it > 0L }
)
return DownloadManagerClient( return DownloadManagerClient(
context = context, context = context,
@ -137,52 +132,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
) )
} }
private fun createNewFilePath(
hexHash: String,
downloadSource: MediaDownloadSource,
mediaAuthor: String,
creationTimestamp: Long?
): String {
val pathFormat by context.config.downloader.pathFormat
val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis())
val finalPath = StringBuilder()
fun appendFileName(string: String) {
if (finalPath.isEmpty() || finalPath.endsWith("/")) {
finalPath.append(string)
} else {
finalPath.append("_").append(string)
}
}
if (pathFormat.contains("create_author_folder")) {
finalPath.append(sanitizedMediaAuthor).append("/")
}
if (pathFormat.contains("create_source_folder")) {
finalPath.append(downloadSource.pathName).append("/")
}
if (pathFormat.contains("append_hash")) {
appendFileName(hexHash)
}
if (pathFormat.contains("append_source")) {
appendFileName(downloadSource.pathName)
}
if (pathFormat.contains("append_username")) {
appendFileName(sanitizedMediaAuthor)
}
if (pathFormat.contains("append_date_time")) {
appendFileName(currentDateTime)
}
if (finalPath.isEmpty()) finalPath.append(hexHash)
return finalPath.toString()
}
/* /*
* Download the last seen media * Download the last seen media
*/ */