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" />
<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>
</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.InputMedia
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.RemoteMediaResolver
import me.rhunk.snapenhance.task.PendingTask
@ -35,7 +34,6 @@ import java.io.File
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.TransformerFactory
@ -44,7 +42,6 @@ import javax.xml.transform.stream.StreamResult
import kotlin.coroutines.coroutineContext
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.absoluteValue
data class DownloadedFile(
val file: File,
@ -331,11 +328,8 @@ class DownloadProcessor (
return newFile
}
fun onReceive(intent: Intent) {
fun enqueue(downloadRequest: DownloadRequest, downloadMetadata: DownloadMetadata) {
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.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),
) {
var storedMessagesCount by remember { mutableIntStateOf(0) }
var storedStoriesCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
}
}
Row(
@ -134,7 +136,13 @@ class SettingsSection(
.fillMaxWidth()
.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 = {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
@ -153,8 +161,9 @@ class SettingsSection(
}
Button(onClick = {
runCatching {
context.messageLogger.clearMessages()
context.messageLogger.clearAll()
storedMessagesCount = 0
storedStoriesCount = 0
}.onFailure {
context.log.error("Failed to clear messages", it)
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
@ -143,7 +144,7 @@ class ScopeContent(
val hours = minutes / 60
val days = hours / 24
if (days > 0) {
stringBuilder.append("$days days ")
stringBuilder.append("$days day ")
return stringBuilder.toString()
}
if (hours > 0) {
@ -201,6 +202,22 @@ class ScopeContent(
}
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 {
//streaks
streaks?.let {
@ -241,6 +258,7 @@ class ScopeContent(
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// e2ee section
SectionTitle(translation["e2ee_title"])

View File

@ -44,6 +44,7 @@ class SocialSection : Section() {
companion object {
const val MAIN_ROUTE = "social_route"
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
@ -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 ->
val id = navBackStackEntry.arguments?.getString("id") ?: 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
*/
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",
"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": {
"name": "App Passcode",
"description": "Sets a passcode to lock the app"

View File

@ -4,7 +4,11 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import kotlinx.coroutines.*
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.ktx.getBlobOrNull
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
import java.io.File
import java.util.UUID
@ -25,6 +29,15 @@ class MessageLoggerWrapper(
"conversation_id VARCHAR",
"message_id BIGINT",
"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
@ -89,9 +102,10 @@ class MessageLoggerWrapper(
return true
}
fun clearMessages() {
fun clearAll() {
coroutineScope.launch {
database.execSQL("DELETE FROM messages")
database.execSQL("DELETE FROM stories")
}
}
@ -103,9 +117,54 @@ class MessageLoggerWrapper(
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) {
coroutineScope.launch {
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 spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }
val appPasscode = string("app_passcode")
val appLockOnResume = boolean("app_lock_on_resume")
val infiniteStoryBoost = boolean("infinite_story_boost")

View File

@ -71,3 +71,12 @@ data class MessagingFriendInfo(
val bitmojiId: String?,
val selfieId: String?
) : 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
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 InputMedia(
@ -25,4 +29,55 @@ class DownloadRequest(
val shouldMergeOverlay: Boolean
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"),
PUBLIC_STORY("public_story", "Public Story", "public_story"),
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 {
if (source == null) return false

View File

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

View File

@ -1,16 +1,23 @@
package me.rhunk.snapenhance.core.features.impl
import kotlinx.coroutines.launch
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.ProtoReader
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.features.FeatureLoadParams
import java.nio.ByteBuffer
import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
@OptIn(ExperimentalEncodingApi::class)
override fun init() {
val disablePublicStories by context.config.global.disablePublicStories
val storyLogger by context.config.experimental.storyLogger
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
fun cancelRequest() {
@ -42,8 +49,8 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
}
}.toByteArray()
}
return@subscribe
}
if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) {
event.onSuccess { buffer ->
val payload = ProtoEditor(buffer ?: return@onSuccess).apply {
@ -53,6 +60,42 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
}
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.common.data.FileType
import me.rhunk.snapenhance.common.data.MessagingRuleType
import me.rhunk.snapenhance.common.data.download.DownloadMediaType
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.data.download.*
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
import me.rhunk.snapenhance.common.database.impl.FriendInfo
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 java.io.ByteArrayInputStream
import java.nio.file.Paths
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.UUID
import kotlin.coroutines.suspendCoroutine
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.absoluteValue
private fun String.sanitizeForPath(): String {
return this.replace(" ", "_")
.replace(Regex("\\p{Cntrl}"), "")
}
class SnapChapterInfo(
val offset: Long,
val duration: Long?
@ -100,7 +89,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
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(
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
*/