mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-28 12:30:12 +02:00
feat(experimental): story logger
This commit is contained in:
parent
195dd278d8
commit
614d629f07
@ -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>
|
@ -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)
|
||||
}
|
||||
}
|
@ -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}")
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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"])
|
||||
|
@ -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
|
||||
|
4
app/src/main/res/xml/provider_paths.xml
Normal file
4
app/src/main/res/xml/provider_paths.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="external_files" path="."/>
|
||||
</paths>
|
@ -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);
|
||||
}
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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()
|
@ -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()
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user