mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-29 21:10:20 +02:00
feat(experimental): story logger
This commit is contained in:
parent
195dd278d8
commit
614d629f07
@ -58,6 +58,16 @@
|
|||||||
android:exported="true" />
|
android:exported="true" />
|
||||||
|
|
||||||
<receiver android:name=".messaging.StreaksReminder" />
|
<receiver android:name=".messaging.StreaksReminder" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="me.rhunk.snapenhance.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@ -23,7 +23,6 @@ import me.rhunk.snapenhance.common.data.download.DownloadMetadata
|
|||||||
import me.rhunk.snapenhance.common.data.download.DownloadRequest
|
import me.rhunk.snapenhance.common.data.download.DownloadRequest
|
||||||
import me.rhunk.snapenhance.common.data.download.InputMedia
|
import me.rhunk.snapenhance.common.data.download.InputMedia
|
||||||
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
|
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
|
||||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
|
||||||
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
|
import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper
|
||||||
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
|
import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver
|
||||||
import me.rhunk.snapenhance.task.PendingTask
|
import me.rhunk.snapenhance.task.PendingTask
|
||||||
@ -35,7 +34,6 @@ import java.io.File
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
import javax.xml.transform.TransformerFactory
|
import javax.xml.transform.TransformerFactory
|
||||||
@ -44,7 +42,6 @@ import javax.xml.transform.stream.StreamResult
|
|||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
|
|
||||||
data class DownloadedFile(
|
data class DownloadedFile(
|
||||||
val file: File,
|
val file: File,
|
||||||
@ -331,11 +328,8 @@ class DownloadProcessor (
|
|||||||
return newFile
|
return newFile
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onReceive(intent: Intent) {
|
fun enqueue(downloadRequest: DownloadRequest, downloadMetadata: DownloadMetadata) {
|
||||||
remoteSideContext.coroutineScope.launch {
|
remoteSideContext.coroutineScope.launch {
|
||||||
val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
|
|
||||||
val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
|
|
||||||
|
|
||||||
remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task ->
|
remoteSideContext.taskManager.getTaskByHash(downloadMetadata.mediaIdentifier)?.let { task ->
|
||||||
remoteSideContext.log.debug("already queued or downloaded")
|
remoteSideContext.log.debug("already queued or downloaded")
|
||||||
|
|
||||||
@ -451,4 +445,11 @@ class DownloadProcessor (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onReceive(intent: Intent) {
|
||||||
|
val downloadMetadata = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_METADATA_EXTRA)!!, DownloadMetadata::class.java)
|
||||||
|
val downloadRequest = gson.fromJson(intent.getStringExtra(ReceiversConfig.DOWNLOAD_REQUEST_EXTRA)!!, DownloadRequest::class.java)
|
||||||
|
|
||||||
|
enqueue(downloadRequest, downloadMetadata)
|
||||||
|
}
|
||||||
}
|
}
|
@ -122,9 +122,11 @@ class SettingsSection(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
var storedMessagesCount by remember { mutableIntStateOf(0) }
|
var storedMessagesCount by remember { mutableIntStateOf(0) }
|
||||||
|
var storedStoriesCount by remember { mutableIntStateOf(0) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||||
|
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@ -134,7 +136,13 @@ class SettingsSection(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(5.dp)
|
.padding(5.dp)
|
||||||
) {
|
) {
|
||||||
Text(text = "$storedMessagesCount messages", modifier = Modifier.weight(1f))
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(text = "$storedMessagesCount messages")
|
||||||
|
Text(text = "$storedStoriesCount stories")
|
||||||
|
}
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
runCatching {
|
runCatching {
|
||||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||||
@ -153,8 +161,9 @@ class SettingsSection(
|
|||||||
}
|
}
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.messageLogger.clearMessages()
|
context.messageLogger.clearAll()
|
||||||
storedMessagesCount = 0
|
storedMessagesCount = 0
|
||||||
|
storedStoriesCount = 0
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.log.error("Failed to clear messages", it)
|
context.log.error("Failed to clear messages", it)
|
||||||
context.longToast("Failed to clear messages! ${it.localizedMessage}")
|
context.longToast("Failed to clear messages! ${it.localizedMessage}")
|
||||||
|
@ -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.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
@ -143,7 +144,7 @@ class ScopeContent(
|
|||||||
val hours = minutes / 60
|
val hours = minutes / 60
|
||||||
val days = hours / 24
|
val days = hours / 24
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
stringBuilder.append("$days days ")
|
stringBuilder.append("$days day ")
|
||||||
return stringBuilder.toString()
|
return stringBuilder.toString()
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
@ -201,6 +202,22 @@ class ScopeContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (context.config.root.experimental.storyLogger.get()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Button(onClick = {
|
||||||
|
navController.navigate(SocialSection.LOGGED_STORIES_ROUTE.replace("{userId}", id))
|
||||||
|
}) {
|
||||||
|
Text("Show Logged Stories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
//streaks
|
//streaks
|
||||||
streaks?.let {
|
streaks?.let {
|
||||||
@ -241,6 +258,7 @@ class ScopeContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
// e2ee section
|
// e2ee section
|
||||||
|
|
||||||
SectionTitle(translation["e2ee_title"])
|
SectionTitle(translation["e2ee_title"])
|
||||||
|
@ -44,6 +44,7 @@ class SocialSection : Section() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val MAIN_ROUTE = "social_route"
|
const val MAIN_ROUTE = "social_route"
|
||||||
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}"
|
const val MESSAGING_PREVIEW_ROUTE = "messaging_preview/?id={id}&scope={scope}"
|
||||||
|
const val LOGGED_STORIES_ROUTE = "logged_stories/?userId={userId}"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentScopeContent: ScopeContent? = null
|
private var currentScopeContent: ScopeContent? = null
|
||||||
@ -84,6 +85,11 @@ class SocialSection : Section() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(LOGGED_STORIES_ROUTE) {
|
||||||
|
val userId = it.arguments?.getString("userId") ?: return@composable
|
||||||
|
LoggedStories(context, userId)
|
||||||
|
}
|
||||||
|
|
||||||
composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
|
composable(MESSAGING_PREVIEW_ROUTE) { navBackStackEntry ->
|
||||||
val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
|
val id = navBackStackEntry.arguments?.getString("id") ?: return@composable
|
||||||
val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable
|
val scope = navBackStackEntry.arguments?.getString("scope") ?: return@composable
|
||||||
|
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
|
* Delete a message from the message logger database
|
||||||
*/
|
*/
|
||||||
void deleteMessage(String conversationId, long id);
|
void deleteMessage(String conversationId, long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a story to the message logger database if it is not already there
|
||||||
|
*/
|
||||||
|
boolean addStory(String userId, String url, long postedAt, long createdAt, in byte[] key, in byte[] iv);
|
||||||
}
|
}
|
@ -597,6 +597,10 @@
|
|||||||
"name": "Convert Message Locally",
|
"name": "Convert Message Locally",
|
||||||
"description": "Converts snaps to chat external media locally. This appears in chat download context menu"
|
"description": "Converts snaps to chat external media locally. This appears in chat download context menu"
|
||||||
},
|
},
|
||||||
|
"story_logger": {
|
||||||
|
"name": "Story Logger",
|
||||||
|
"description": "Provides a history of friends stories"
|
||||||
|
},
|
||||||
"app_passcode": {
|
"app_passcode": {
|
||||||
"name": "App Passcode",
|
"name": "App Passcode",
|
||||||
"description": "Sets a passcode to lock the app"
|
"description": "Sets a passcode to lock the app"
|
||||||
|
@ -4,7 +4,11 @@ import android.content.ContentValues
|
|||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import me.rhunk.snapenhance.bridge.MessageLoggerInterface
|
import me.rhunk.snapenhance.bridge.MessageLoggerInterface
|
||||||
|
import me.rhunk.snapenhance.common.data.StoryData
|
||||||
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
|
import me.rhunk.snapenhance.common.util.SQLiteDatabaseHelper
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getBlobOrNull
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
|
||||||
|
import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@ -25,6 +29,15 @@ class MessageLoggerWrapper(
|
|||||||
"conversation_id VARCHAR",
|
"conversation_id VARCHAR",
|
||||||
"message_id BIGINT",
|
"message_id BIGINT",
|
||||||
"message_data BLOB"
|
"message_data BLOB"
|
||||||
|
),
|
||||||
|
"stories" to listOf(
|
||||||
|
"id INTEGER PRIMARY KEY",
|
||||||
|
"user_id VARCHAR",
|
||||||
|
"posted_timestamp BIGINT",
|
||||||
|
"created_timestamp BIGINT",
|
||||||
|
"url VARCHAR",
|
||||||
|
"encryption_key BLOB",
|
||||||
|
"encryption_iv BLOB"
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
_database = openedDatabase
|
_database = openedDatabase
|
||||||
@ -89,9 +102,10 @@ class MessageLoggerWrapper(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearMessages() {
|
fun clearAll() {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
database.execSQL("DELETE FROM messages")
|
database.execSQL("DELETE FROM messages")
|
||||||
|
database.execSQL("DELETE FROM stories")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,9 +117,54 @@ class MessageLoggerWrapper(
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getStoredStoriesCount(): Int {
|
||||||
|
val cursor = database.rawQuery("SELECT COUNT(*) FROM stories", null)
|
||||||
|
cursor.moveToFirst()
|
||||||
|
val count = cursor.getInt(0)
|
||||||
|
cursor.close()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteMessage(conversationId: String, messageId: Long) {
|
override fun deleteMessage(conversationId: String, messageId: Long) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
|
database.execSQL("DELETE FROM messages WHERE conversation_id = ? AND message_id = ?", arrayOf(conversationId, messageId.toString()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun addStory(userId: String, url: String, postedAt: Long, createdAt: Long, key: ByteArray?, iv: ByteArray?): Boolean {
|
||||||
|
if (database.rawQuery("SELECT id FROM stories WHERE user_id = ? AND url = ?", arrayOf(userId, url)).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runBlocking {
|
||||||
|
withContext(coroutineScope.coroutineContext) {
|
||||||
|
database.insert("stories", null, ContentValues().apply {
|
||||||
|
put("user_id", userId)
|
||||||
|
put("url", url)
|
||||||
|
put("posted_timestamp", postedAt)
|
||||||
|
put("created_timestamp", createdAt)
|
||||||
|
put("encryption_key", key)
|
||||||
|
put("encryption_iv", iv)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getStories(userId: String, from: Long, limit: Int = Int.MAX_VALUE): Map<Long, StoryData> {
|
||||||
|
val stories = sortedMapOf<Long, StoryData>()
|
||||||
|
database.rawQuery("SELECT * FROM stories WHERE user_id = ? AND posted_timestamp < ? ORDER BY posted_timestamp DESC LIMIT $limit", arrayOf(userId, from.toString())).use {
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
stories[it.getLongOrNull("posted_timestamp") ?: continue] = StoryData(
|
||||||
|
url = it.getStringOrNull("url") ?: continue,
|
||||||
|
postedAt = it.getLongOrNull("posted_timestamp") ?: continue,
|
||||||
|
createdAt = it.getLongOrNull("created_timestamp") ?: continue,
|
||||||
|
key = it.getBlobOrNull("encryption_key"),
|
||||||
|
iv = it.getBlobOrNull("encryption_iv")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stories
|
||||||
|
}
|
||||||
}
|
}
|
@ -11,6 +11,7 @@ class Experimental : ConfigContainer() {
|
|||||||
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() }
|
val nativeHooks = container("native_hooks", NativeHooks()) { icon = "Memory"; requireRestart() }
|
||||||
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
|
val spoof = container("spoof", Spoof()) { icon = "Fingerprint" ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
|
||||||
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
|
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
|
||||||
|
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }
|
||||||
val appPasscode = string("app_passcode")
|
val appPasscode = string("app_passcode")
|
||||||
val appLockOnResume = boolean("app_lock_on_resume")
|
val appLockOnResume = boolean("app_lock_on_resume")
|
||||||
val infiniteStoryBoost = boolean("infinite_story_boost")
|
val infiniteStoryBoost = boolean("infinite_story_boost")
|
||||||
|
@ -71,3 +71,12 @@ data class MessagingFriendInfo(
|
|||||||
val bitmojiId: String?,
|
val bitmojiId: String?,
|
||||||
val selfieId: String?
|
val selfieId: String?
|
||||||
) : SerializableDataObject()
|
) : SerializableDataObject()
|
||||||
|
|
||||||
|
|
||||||
|
class StoryData(
|
||||||
|
val url: String,
|
||||||
|
val postedAt: Long,
|
||||||
|
val createdAt: Long,
|
||||||
|
val key: ByteArray?,
|
||||||
|
val iv: ByteArray?
|
||||||
|
) : SerializableDataObject()
|
@ -1,5 +1,9 @@
|
|||||||
package me.rhunk.snapenhance.common.data.download
|
package me.rhunk.snapenhance.common.data.download
|
||||||
|
|
||||||
|
import me.rhunk.snapenhance.common.config.impl.RootConfig
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
data class DashOptions(val offsetTime: Long, val duration: Long?)
|
data class DashOptions(val offsetTime: Long, val duration: Long?)
|
||||||
data class InputMedia(
|
data class InputMedia(
|
||||||
@ -25,4 +29,55 @@ class DownloadRequest(
|
|||||||
|
|
||||||
val shouldMergeOverlay: Boolean
|
val shouldMergeOverlay: Boolean
|
||||||
get() = flags and Flags.MERGE_OVERLAY != 0
|
get() = flags and Flags.MERGE_OVERLAY != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.sanitizeForPath(): String {
|
||||||
|
return this.replace(" ", "_")
|
||||||
|
.replace(Regex("\\p{Cntrl}"), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNewFilePath(
|
||||||
|
config: RootConfig,
|
||||||
|
hexHash: String,
|
||||||
|
downloadSource: MediaDownloadSource,
|
||||||
|
mediaAuthor: String,
|
||||||
|
creationTimestamp: Long?
|
||||||
|
): String {
|
||||||
|
val pathFormat by config.downloader.pathFormat
|
||||||
|
val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
|
||||||
|
|
||||||
|
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis())
|
||||||
|
|
||||||
|
val finalPath = StringBuilder()
|
||||||
|
|
||||||
|
fun appendFileName(string: String) {
|
||||||
|
if (finalPath.isEmpty() || finalPath.endsWith("/")) {
|
||||||
|
finalPath.append(string)
|
||||||
|
} else {
|
||||||
|
finalPath.append("_").append(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathFormat.contains("create_author_folder")) {
|
||||||
|
finalPath.append(sanitizedMediaAuthor).append("/")
|
||||||
|
}
|
||||||
|
if (pathFormat.contains("create_source_folder")) {
|
||||||
|
finalPath.append(downloadSource.pathName).append("/")
|
||||||
|
}
|
||||||
|
if (pathFormat.contains("append_hash")) {
|
||||||
|
appendFileName(hexHash)
|
||||||
|
}
|
||||||
|
if (pathFormat.contains("append_source")) {
|
||||||
|
appendFileName(downloadSource.pathName)
|
||||||
|
}
|
||||||
|
if (pathFormat.contains("append_username")) {
|
||||||
|
appendFileName(sanitizedMediaAuthor)
|
||||||
|
}
|
||||||
|
if (pathFormat.contains("append_date_time")) {
|
||||||
|
appendFileName(currentDateTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalPath.isEmpty()) finalPath.append(hexHash)
|
||||||
|
|
||||||
|
return finalPath.toString()
|
||||||
}
|
}
|
@ -12,7 +12,8 @@ enum class MediaDownloadSource(
|
|||||||
STORY("story", "Story", "story"),
|
STORY("story", "Story", "story"),
|
||||||
PUBLIC_STORY("public_story", "Public Story", "public_story"),
|
PUBLIC_STORY("public_story", "Public Story", "public_story"),
|
||||||
SPOTLIGHT("spotlight", "Spotlight", "spotlight"),
|
SPOTLIGHT("spotlight", "Spotlight", "spotlight"),
|
||||||
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture");
|
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"),
|
||||||
|
STORY_LOGGER("story_logger", "Story Logger", "story_logger");
|
||||||
|
|
||||||
fun matches(source: String?): Boolean {
|
fun matches(source: String?): Boolean {
|
||||||
if (source == null) return false
|
if (source == null) return false
|
||||||
|
@ -22,7 +22,7 @@ object MediaDownloaderHelper {
|
|||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
callback: (SplitMediaAssetType, InputStream) -> Unit
|
callback: (SplitMediaAssetType, InputStream) -> Unit
|
||||||
) {
|
) {
|
||||||
val bufferedInputStream = BufferedInputStream(inputStream)
|
val bufferedInputStream = inputStream.buffered()
|
||||||
val fileType = getFileType(bufferedInputStream)
|
val fileType = getFileType(bufferedInputStream)
|
||||||
|
|
||||||
if (fileType != FileType.ZIP) {
|
if (fileType != FileType.ZIP) {
|
||||||
@ -30,16 +30,16 @@ object MediaDownloaderHelper {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val zipInputStream = ZipInputStream(bufferedInputStream)
|
ZipInputStream(bufferedInputStream).use { zipInputStream ->
|
||||||
|
var entry: ZipEntry? = zipInputStream.nextEntry
|
||||||
var entry: ZipEntry? = zipInputStream.nextEntry
|
while (entry != null) {
|
||||||
while (entry != null) {
|
if (entry.name.startsWith("overlay")) {
|
||||||
if (entry.name.startsWith("overlay")) {
|
callback(SplitMediaAssetType.OVERLAY, zipInputStream)
|
||||||
callback(SplitMediaAssetType.OVERLAY, zipInputStream)
|
} else if (entry.name.startsWith("media")) {
|
||||||
} else if (entry.name.startsWith("media")) {
|
callback(SplitMediaAssetType.ORIGINAL, zipInputStream)
|
||||||
callback(SplitMediaAssetType.ORIGINAL, zipInputStream)
|
}
|
||||||
|
entry = zipInputStream.nextEntry
|
||||||
}
|
}
|
||||||
entry = zipInputStream.nextEntry
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,16 +1,23 @@
|
|||||||
package me.rhunk.snapenhance.core.features.impl
|
package me.rhunk.snapenhance.core.features.impl
|
||||||
|
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import me.rhunk.snapenhance.common.data.StoryData
|
||||||
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
|
import me.rhunk.snapenhance.common.util.protobuf.ProtoEditor
|
||||||
|
import me.rhunk.snapenhance.common.util.protobuf.ProtoReader
|
||||||
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
|
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
|
||||||
import me.rhunk.snapenhance.core.features.Feature
|
import me.rhunk.snapenhance.core.features.Feature
|
||||||
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
import me.rhunk.snapenhance.core.features.FeatureLoadParams
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
|
class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
override fun init() {
|
override fun init() {
|
||||||
val disablePublicStories by context.config.global.disablePublicStories
|
val disablePublicStories by context.config.global.disablePublicStories
|
||||||
|
val storyLogger by context.config.experimental.storyLogger
|
||||||
|
|
||||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||||
fun cancelRequest() {
|
fun cancelRequest() {
|
||||||
@ -42,8 +49,8 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
|
|||||||
}
|
}
|
||||||
}.toByteArray()
|
}.toByteArray()
|
||||||
}
|
}
|
||||||
|
return@subscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) {
|
if (disablePublicStories && (event.url.endsWith("df-mixer-prod/stories") || event.url.endsWith("df-mixer-prod/batch_stories"))) {
|
||||||
event.onSuccess { buffer ->
|
event.onSuccess { buffer ->
|
||||||
val payload = ProtoEditor(buffer ?: return@onSuccess).apply {
|
val payload = ProtoEditor(buffer ?: return@onSuccess).apply {
|
||||||
@ -53,6 +60,42 @@ class Stories : Feature("Stories", loadParams = FeatureLoadParams.INIT_SYNC) {
|
|||||||
}
|
}
|
||||||
return@subscribe
|
return@subscribe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (storyLogger && event.url.endsWith("df-mixer-prod/soma/batch_stories")) {
|
||||||
|
event.onSuccess { buffer ->
|
||||||
|
val stories = mutableMapOf<String, MutableList<StoryData>>()
|
||||||
|
val reader = ProtoReader(buffer ?: return@onSuccess)
|
||||||
|
reader.followPath(3, 3) {
|
||||||
|
eachBuffer(3) {
|
||||||
|
followPath(36) {
|
||||||
|
eachBuffer(1) data@{
|
||||||
|
val userId = getString(8, 1) ?: return@data
|
||||||
|
|
||||||
|
stories.getOrPut(userId) {
|
||||||
|
mutableListOf()
|
||||||
|
}.add(StoryData(
|
||||||
|
url = getString(2, 2)?.substringBefore("?") ?: return@data,
|
||||||
|
postedAt = getVarInt(3) ?: -1L,
|
||||||
|
createdAt = getVarInt(27) ?: -1L,
|
||||||
|
key = Base64.decode(getString(2, 5) ?: return@data),
|
||||||
|
iv = Base64.decode(getString(2, 4) ?: return@data)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.coroutineScope.launch {
|
||||||
|
stories.forEach { (userId, stories) ->
|
||||||
|
stories.forEach { story ->
|
||||||
|
context.bridgeClient.getMessageLogger().addStory(userId, story.url, story.postedAt, story.createdAt, story.key, story.iv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@subscribe
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -18,11 +18,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import me.rhunk.snapenhance.bridge.DownloadCallback
|
import me.rhunk.snapenhance.bridge.DownloadCallback
|
||||||
import me.rhunk.snapenhance.common.data.FileType
|
import me.rhunk.snapenhance.common.data.FileType
|
||||||
import me.rhunk.snapenhance.common.data.MessagingRuleType
|
import me.rhunk.snapenhance.common.data.MessagingRuleType
|
||||||
import me.rhunk.snapenhance.common.data.download.DownloadMediaType
|
import me.rhunk.snapenhance.common.data.download.*
|
||||||
import me.rhunk.snapenhance.common.data.download.DownloadMetadata
|
|
||||||
import me.rhunk.snapenhance.common.data.download.InputMedia
|
|
||||||
import me.rhunk.snapenhance.common.data.download.MediaDownloadSource
|
|
||||||
import me.rhunk.snapenhance.common.data.download.SplitMediaAssetType
|
|
||||||
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
|
import me.rhunk.snapenhance.common.database.impl.ConversationMessage
|
||||||
import me.rhunk.snapenhance.common.database.impl.FriendInfo
|
import me.rhunk.snapenhance.common.database.impl.FriendInfo
|
||||||
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
import me.rhunk.snapenhance.common.util.ktx.longHashCode
|
||||||
@ -53,19 +49,12 @@ import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap
|
|||||||
import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair
|
import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
private fun String.sanitizeForPath(): String {
|
|
||||||
return this.replace(" ", "_")
|
|
||||||
.replace(Regex("\\p{Cntrl}"), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnapChapterInfo(
|
class SnapChapterInfo(
|
||||||
val offset: Long,
|
val offset: Long,
|
||||||
val duration: Long?
|
val duration: Long?
|
||||||
@ -100,7 +89,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
context.shortToast(translations["download_started_toast"])
|
context.shortToast(translations["download_started_toast"])
|
||||||
}
|
}
|
||||||
|
|
||||||
val outputPath = createNewFilePath(generatedHash.substring(0, generatedHash.length.coerceAtMost(8)), downloadSource, mediaAuthor, creationTimestamp?.takeIf { it > 0L })
|
val outputPath = createNewFilePath(
|
||||||
|
context.config,
|
||||||
|
generatedHash.substring(0, generatedHash.length.coerceAtMost(8)),
|
||||||
|
downloadSource,
|
||||||
|
mediaAuthor,
|
||||||
|
creationTimestamp?.takeIf { it > 0L }
|
||||||
|
)
|
||||||
|
|
||||||
return DownloadManagerClient(
|
return DownloadManagerClient(
|
||||||
context = context,
|
context = context,
|
||||||
@ -137,52 +132,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun createNewFilePath(
|
|
||||||
hexHash: String,
|
|
||||||
downloadSource: MediaDownloadSource,
|
|
||||||
mediaAuthor: String,
|
|
||||||
creationTimestamp: Long?
|
|
||||||
): String {
|
|
||||||
val pathFormat by context.config.downloader.pathFormat
|
|
||||||
val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash }
|
|
||||||
|
|
||||||
val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis())
|
|
||||||
|
|
||||||
val finalPath = StringBuilder()
|
|
||||||
|
|
||||||
fun appendFileName(string: String) {
|
|
||||||
if (finalPath.isEmpty() || finalPath.endsWith("/")) {
|
|
||||||
finalPath.append(string)
|
|
||||||
} else {
|
|
||||||
finalPath.append("_").append(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathFormat.contains("create_author_folder")) {
|
|
||||||
finalPath.append(sanitizedMediaAuthor).append("/")
|
|
||||||
}
|
|
||||||
if (pathFormat.contains("create_source_folder")) {
|
|
||||||
finalPath.append(downloadSource.pathName).append("/")
|
|
||||||
}
|
|
||||||
if (pathFormat.contains("append_hash")) {
|
|
||||||
appendFileName(hexHash)
|
|
||||||
}
|
|
||||||
if (pathFormat.contains("append_source")) {
|
|
||||||
appendFileName(downloadSource.pathName)
|
|
||||||
}
|
|
||||||
if (pathFormat.contains("append_username")) {
|
|
||||||
appendFileName(sanitizedMediaAuthor)
|
|
||||||
}
|
|
||||||
if (pathFormat.contains("append_date_time")) {
|
|
||||||
appendFileName(currentDateTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalPath.isEmpty()) finalPath.append(hexHash)
|
|
||||||
|
|
||||||
return finalPath.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Download the last seen media
|
* Download the last seen media
|
||||||
*/
|
*/
|
||||||
|
Loading…
x
Reference in New Issue
Block a user