diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 91ed83d4..a541905d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -58,6 +58,16 @@
android:exported="true" />
+
+
+
+
\ 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
index bbc8576f..3d329d14 100644
--- 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
index f849efc0..456b0433 100644
--- 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
new file mode 100644
index 00000000..72b870f6
--- /dev/null
+++ 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()
+ }
+ val friendInfo = remember {
+ context.modDatabase.getFriendInfo(userId)
+ }
+ val httpClient = remember { OkHttpClient() }
+ var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
+
+ var selectedStory by remember { mutableStateOf(null) }
+ var coilCacheFile by remember { mutableStateOf(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(null) }
+ val uniqueHash = remember { story.url.hashCode().absoluteValue.toString(16) }
+
+ fun openDiskCacheSnapshot(snapshot: DiskCache.Snapshot): Boolean {
+ runCatching {
+ val mediaList = mutableMapOf()
+
+ 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
index 2e0e4b90..ef7566b5 100644
--- 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
index 3f4acded..56a83052 100644
--- 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
new file mode 100644
index 00000000..8d13fa17
--- /dev/null
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl b/common/src/main/aidl/me/rhunk/snapenhance/bridge/MessageLoggerInterface.aidl
index f1a4d6c7..09f7fb32 100644
--- 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
index 3d33b375..ca08d03a 100644
--- 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
index 9f757bd2..7de47e1c 100644
--- 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 {
+ val stories = sortedMapOf()
+ 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
index c9177deb..40c30c2a 100644
--- 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
index ef7eccd9..a8ee2d46 100644
--- 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
index d3ad0f82..71f1f232 100644
--- 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
index 4cd0840f..034a72a6 100644
--- 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
index 7538450a..6e9d206f 100644
--- 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
index f531748e..6d1d9cc9 100644
--- 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>()
+ 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
index b9bad80b..a69c3fe2 100644
--- 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
*/