commit 614d629f07f84d3ac51294b915f3f517fedadb80
parent 195dd278d826fb2a9383025b62c043d1a2e2d080
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 17 Dec 2023 02:50:01 +0100

feat(experimental): story logger

Diffstat:
Mapp/src/main/AndroidManifest.xml | 10++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt | 15++++++++-------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt | 13+++++++++++--
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt | 20+++++++++++++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt | 6++++++
Aapp/src/main/res/xml/provider_paths.xml | 4++++
Mcommon/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl | 5+++++
Mcommon/src/main/assets/lang/en_US.json | 4++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt | 1+
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt | 10++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt | 3++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt | 20++++++++++----------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt | 45++++++++++++++++++++++++++++++++++++++++++++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 67++++++++-----------------------------------------------------------
17 files changed, 525 insertions(+), 82 deletions(-)

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml @@ -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> \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/home/SettingsSection.kt @@ -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}") diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt @@ -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 + } + } + } + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/ScopeContent.kt @@ -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"]) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/SocialSection.kt @@ -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 diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <external-path name="external_files" path="."/> +</paths> diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl @@ -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); } \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -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" diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt @@ -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 + } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/Experimental.kt @@ -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") diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/MessagingCoreObjects.kt @@ -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()+ \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt @@ -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() } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt @@ -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 diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/snap/MediaDownloaderHelper.kt @@ -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 } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/Stories.kt @@ -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 + } } } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -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 */