commit dd755af5be3be51fab88f09042cfca5d0f76a830
parent 04b70431c7b384bae81a79ebbdc9675046a9a643
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Sun, 31 Dec 2023 00:33:38 +0100
feat: export memories
Diffstat:
6 files changed, 436 insertions(+), 4 deletions(-)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
@@ -6,5 +6,6 @@
-keep class org.jf.dexlib2.** { *; }
-keep class org.mozilla.javascript.** { *; }
-keep class androidx.compose.material.icons.** { *; }
+-keep class androidx.compose.material3.R$* { *; }
-keep class androidx.navigation.** { *; }
-keep class me.rhunk.snapenhance.** { *; }
diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json
@@ -128,6 +128,7 @@
"open_map": "Choose location on map",
"check_for_updates": "Check for updates",
"export_chat_messages": "Export Chat Messages",
+ "export_memories": "Export Memories",
"bulk_messaging_action": "Bulk Messaging Action"
},
@@ -1075,5 +1076,21 @@
"suspend_location_updates": {
"switch_text": "Suspend Location Updates"
+ },
+ "material3_strings": {
+ "date_range_input_title": "",
+ "date_range_picker_start_headline": "From",
+ "date_range_picker_end_headline": "To",
+ "date_range_picker_title": "Select date range",
+ "date_picker_switch_to_calendar_mode": "Calendar",
+ "date_picker_switch_to_input_mode": "Input",
+ "date_range_picker_scroll_to_previous_month": "Previous month",
+ "date_range_picker_scroll_to_next_month": "Next month",
+ "date_picker_today_description": "Today",
+ "date_range_picker_day_in_range": "Selected",
+ "date_input_invalid_for_pattern": "Invalid date",
+ "date_input_invalid_year_range": "Invalid year",
+ "date_input_invalid_not_allowed": "Invalid date",
+ "date_range_input_invalid_range_input": "Invalid date range"
}
}
\ No newline at end of file
diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt
@@ -9,6 +9,7 @@ enum class EnumAction(
) {
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true),
EXPORT_CHAT_MESSAGES("export_chat_messages"),
+ EXPORT_MEMORIES("export_memories"),
BULK_MESSAGING_ACTION("bulk_messaging_action");
companion object {
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt
@@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core
import android.app.Activity
import android.app.Application
import android.content.Context
+import android.content.res.Resources
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -20,8 +21,10 @@ import me.rhunk.snapenhance.core.data.SnapClassCache
import me.rhunk.snapenhance.core.event.events.impl.NativeUnaryCallEvent
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.util.LSPatchUpdater
+import me.rhunk.snapenhance.core.util.hook.HookAdapter
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
+import java.lang.reflect.Modifier
import kotlin.system.measureTimeMillis
@@ -36,11 +39,11 @@ class SnapEnhance {
private lateinit var appContext: ModContext
private var isBridgeInitialized = false
- private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.() -> Unit) {
+ private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.(param: HookAdapter) -> Unit) {
Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param ->
val activity = param.thisObject() as Activity
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
- block(activity)
+ block(activity, param)
}
}
@@ -90,6 +93,8 @@ class SnapEnhance {
appContext.mainActivity = this
if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hookMainActivity
onActivityCreate()
+ jetpackComposeResourceHook()
+ appContext.actionManager.onNewIntent(intent)
}
hookMainActivity("onPause") {
@@ -97,6 +102,10 @@ class SnapEnhance {
appContext.isMainActivityPaused = true
}
+ hookMainActivity("onNewIntent") { param ->
+ appContext.actionManager.onNewIntent(param.argNullable(0))
+ }
+
var activityWasResumed = false
//we need to reload the config when the app is resumed
//FIXME: called twice at first launch
@@ -107,7 +116,6 @@ class SnapEnhance {
return@hookMainActivity
}
- appContext.actionManager.onNewIntent(this.intent)
appContext.reloadConfig()
syncRemote()
}
@@ -263,4 +271,22 @@ class SnapEnhance {
}
}
}
+
+ private fun jetpackComposeResourceHook() {
+ val material3RString = try {
+ Class.forName("androidx.compose.material3.R\$string")
+ } catch (e: ClassNotFoundException) {
+ return
+ }
+
+ val stringResources = material3RString.fields.filter {
+ Modifier.isStatic(it.modifiers) && it.type == Int::class.javaPrimitiveType
+ }.associate { it.getInt(null) to it.name }
+
+ Resources::class.java.getMethod("getString", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
+ val key = param.arg<Int>(0)
+ val name = stringResources[key] ?: return@hook
+ param.setResult(appContext.translation.getOrNull("material3_strings.$name") ?: return@hook)
+ }
+ }
}
\ No newline at end of file
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt
@@ -0,0 +1,384 @@
+package me.rhunk.snapenhance.core.action.impl
+
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteDatabase.OpenParams
+import android.os.Environment
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.*
+import me.rhunk.snapenhance.common.data.FileType
+import me.rhunk.snapenhance.common.ui.createComposeAlertDialog
+import me.rhunk.snapenhance.common.util.ktx.getLongOrNull
+import me.rhunk.snapenhance.common.util.ktx.getStringOrNull
+import me.rhunk.snapenhance.core.action.AbstractAction
+import okhttp3.OkHttpClient
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.file.attribute.FileTime
+import java.time.Instant
+import java.time.OffsetDateTime
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import javax.crypto.Cipher
+import javax.crypto.CipherInputStream
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlin.math.absoluteValue
+
+class ExportMemories : AbstractAction() {
+ data class TimeRange(
+ val start: Long?,
+ val end: Long?,
+ )
+
+ data class MemoriesEntry(
+ val storyTitle: String,
+ val createTime: Long,
+ val mediaKey: String?,
+ val mediaIv: String?,
+ val downloadUrl: String
+ ) {
+ val folderName: String
+ get() = storyTitle.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim().replace(Regex("\\s+"), "_")
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class, ExperimentalEncodingApi::class)
+ private suspend fun exportMemories(
+ scope: CoroutineScope = context.coroutineScope,
+ database: SQLiteDatabase,
+ timeRange: TimeRange?,
+ includeMEO: Boolean,
+ folders: Boolean,
+ progress: (Int, Int) -> Unit
+ ) {
+ val downloadContext = Dispatchers.IO.limitedParallelism(10)
+ val writeToZipContext = Dispatchers.IO.limitedParallelism(1)
+ val outputZip = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "memories_" + System.currentTimeMillis() + ".zip").also {
+ if (it.exists()) it.delete()
+ }
+ val okHttpClient = OkHttpClient.Builder().build()
+ val outputZipFile = withContext(Dispatchers.IO) {
+ ZipOutputStream(FileOutputStream(outputZip)).apply {
+ setComment("Exported from SnapEnhance")
+ setMethod(ZipOutputStream.DEFLATED)
+ }
+ }
+ var totalCount = 0
+ var currentCount = 0
+ var failed = 0
+
+ fun updateProgress() {
+ progress((currentCount.toFloat() / totalCount.toFloat() * 100f).toInt(), failed)
+ }
+
+ val jobs = mutableListOf<Job>()
+
+ val meoMasterKeyPair = if (includeMEO) {
+ runCatching {
+ database.rawQuery("SELECT * FROM memories_meo_confidential", null).use { cursor ->
+ if (cursor.moveToNext()) {
+ cursor.getStringOrNull("master_key")!!.trim() to cursor.getStringOrNull("master_key_iv")!!.trim()
+ } else null
+ }
+ }.getOrNull()
+ } else null
+
+ database.rawQuery("SELECT memories_entry.title as story_title, memories_snap.create_time, " +
+ "memories_snap.media_key, memories_snap.media_iv, memories_snap.encrypted_media_key, memories_snap.encrypted_media_iv, " +
+ "memories_media.download_url FROM memories_snap " +
+ "INNER JOIN memories_entry ON memories_snap.memories_entry_id = memories_entry._id " +
+ "INNER JOIN memories_media ON memories_snap.media_id = memories_media._id " +
+ "WHERE memories_snap.create_time >= ? AND memories_snap.create_time <= ? " +
+ "ORDER BY memories_snap.create_time ASC", arrayOf(timeRange?.start?.toString() ?: "-1", timeRange?.end?.toString() ?: Long.MAX_VALUE.toString())
+ ).use { cursor ->
+ while (cursor.moveToNext()) {
+ val encryptedMediaKey = cursor.getStringOrNull("encrypted_media_key")?.trim()
+ val encryptedMediaIv = cursor.getStringOrNull("encrypted_media_iv")?.trim()
+ var mediaKey = cursor.getStringOrNull("media_key")?.trim()
+ var mediaIv = cursor.getStringOrNull("media_iv")?.trim()
+
+ if (!includeMEO && encryptedMediaKey != null && encryptedMediaIv != null) continue
+
+ meoMasterKeyPair.takeIf { encryptedMediaKey != null && encryptedMediaIv != null }?.let { keyPair ->
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ runCatching {
+ cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.decode(keyPair.first), "AES"), IvParameterSpec(Base64.decode(keyPair.second)))
+ mediaKey = Base64.encode(cipher.doFinal(Base64.decode(encryptedMediaKey ?: return@let)))
+ mediaIv = Base64.encode(cipher.doFinal(Base64.decode(encryptedMediaIv ?: return@let)))
+ context.log.verbose("decrypted meo $mediaKey/$mediaIv")
+ }.onFailure {
+ context.log.error("failed to decrypt meo", it)
+ }
+ }
+
+ if (mediaKey == null || mediaIv == null) {
+ context.log.error("missing media key or iv for ${cursor.getStringOrNull("download_url")}")
+ failed++
+ updateProgress()
+ continue
+ }
+
+ val entry = MemoriesEntry(
+ storyTitle = cursor.getStringOrNull("story_title") ?: "unknown",
+ createTime = cursor.getLongOrNull("create_time") ?: -1L,
+ mediaKey = mediaKey,
+ mediaIv = mediaIv,
+ downloadUrl = cursor.getStringOrNull("download_url") ?: continue
+ )
+
+ totalCount++
+
+ scope.launch(downloadContext) {
+ var downloadedFile = File.createTempFile("memories", ".tmp", context.androidContext.cacheDir)
+
+ runCatching {
+ okHttpClient.newCall(
+ okhttp3.Request.Builder()
+ .url(entry.downloadUrl)
+ .build()
+ ).execute().use { response ->
+ val inputStream = response.body.byteStream().let {
+ if (entry.mediaKey != null && entry.mediaIv != null) {
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.decode(entry.mediaKey), "AES"), IvParameterSpec(Base64.decode(entry.mediaIv)))
+ CipherInputStream(it, cipher)
+ } else it
+ }
+
+ downloadedFile.outputStream().use { outputStream ->
+ inputStream.use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+
+ val fileType = FileType.fromFile(downloadedFile)
+
+ downloadedFile = File(
+ downloadedFile.parentFile,
+ "${entry.createTime}-${entry.downloadUrl.hashCode().absoluteValue.toString(16)}.${fileType.fileExtension}"
+ ).also {
+ downloadedFile.renameTo(it)
+ }
+
+ withContext(writeToZipContext) {
+ val zipEntry = ZipEntry("${if (folders) entry.folderName + "/" else entry.folderName}${downloadedFile.name}")
+ FileTime.fromMillis(entry.createTime).let {
+ zipEntry.lastModifiedTime = it
+ zipEntry.lastAccessTime = it
+ zipEntry.creationTime = it
+ }
+ outputZipFile.apply {
+ putNextEntry(zipEntry)
+ downloadedFile.inputStream().use { it.copyTo(outputZipFile) }
+ closeEntry()
+ flush()
+ }
+ currentCount++
+ updateProgress()
+ }
+ }
+ }.onFailure {
+ context.log.error("failed to download ${entry.downloadUrl}", it)
+ failed++
+ updateProgress()
+ }
+ downloadedFile.delete()
+ }.also { jobs.add(it) }
+ }
+ }
+
+ jobs.joinAll()
+ withContext(Dispatchers.IO) {
+ outputZipFile.close()
+ }
+ context.longToast("Exported to ${outputZip.absolutePath}")
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun ExporterDialog(database: SQLiteDatabase, onDismiss: () -> Unit) {
+ var exportJob by remember { mutableStateOf(null as Job?) }
+ var exportFinished by remember { mutableStateOf(false) }
+ var exportProgress by remember { mutableStateOf(Pair(0, 0)) } // progress, failed
+
+ var dateRangeFilter by remember { mutableStateOf(false) }
+ var sortByFolder by remember { mutableStateOf(false) }
+ var includeMEO by remember { mutableStateOf(false) }
+ val dateRangePickerState = rememberDateRangePickerState(
+ initialSelectedStartDateMillis = OffsetDateTime.now().minusDays(8).toInstant().toEpochMilli(),
+ initialSelectedEndDateMillis = Instant.now().toEpochMilli(),
+ initialDisplayMode = DisplayMode.Input
+ )
+
+ val totalCount = remember(dateRangePickerState.selectedStartDateMillis, dateRangePickerState.selectedEndDateMillis, dateRangeFilter) {
+ val timeRange = dateRangePickerState.takeIf { dateRangeFilter }?.let {
+ TimeRange(it.selectedStartDateMillis, it.selectedEndDateMillis)
+ }
+
+ database.rawQuery("SELECT COUNT(*) FROM memories_snap WHERE create_time >= ? AND create_time <= ? ", arrayOf(timeRange?.start?.toString() ?: "-1", timeRange?.end?.toString() ?: Long.MAX_VALUE.toString())).use {
+ it.moveToFirst()
+ it.getInt(0)
+ }
+ }
+
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text("Export memories", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, fontSize = 20.sp)
+
+ if (exportJob != null) {
+ Text(text = "Exporting memories... (${exportProgress.second} failed)", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+ LinearProgressIndicator(progress = exportProgress.first / 100f, Modifier.fillMaxWidth())
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(onClick = {
+ exportJob?.cancel()
+ exportJob = null
+ onDismiss()
+ }) {
+ Text("Quit")
+ }
+ if (exportFinished) {
+ Button(onClick = {
+ exportJob = null
+ onDismiss()
+ }) {
+ Text("Done")
+ }
+ }
+ }
+ } else {
+ Text("Total memories: $totalCount", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ var dateRangeDialog by remember { mutableStateOf(false) }
+ Checkbox(checked = dateRangeFilter, onCheckedChange = { dateRangeFilter = it })
+ Text("Date Range", modifier = Modifier.weight(1f))
+ Button(onClick = { dateRangeDialog = true }, enabled = dateRangeFilter) {
+ Text("Select")
+ }
+
+ if (dateRangeDialog) {
+ DatePickerDialog(onDismissRequest = {
+ dateRangeDialog = false
+ }, confirmButton = {}) {
+ DateRangePicker(
+ state = dateRangePickerState,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Button(onClick = {
+ dateRangeDialog = false
+ }) {
+ Text("OK")
+ }
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked = sortByFolder, onCheckedChange = { sortByFolder = it })
+ Text("Sort by folder", modifier = Modifier.weight(1f))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(checked = includeMEO, onCheckedChange = { includeMEO = it })
+ Text("Include My Eyes Only", modifier = Modifier.weight(1f))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ Button(onClick = {
+ context.coroutineScope.launch {
+ exportMemories(
+ scope = this,
+ database = database,
+ timeRange = dateRangePickerState.takeIf { dateRangeFilter }?.let {
+ TimeRange(it.selectedStartDateMillis, it.selectedEndDateMillis)
+ },
+ folders = sortByFolder,
+ includeMEO = includeMEO,
+ ) { progress, failed ->
+ exportProgress = Pair(progress, failed)
+ }
+ }.also { exportJob = it }.invokeOnCompletion {
+ exportFinished = true
+ }
+ }) {
+ Text("Export")
+ }
+ }
+ }
+
+
+ }
+ }
+
+ override fun run() {
+ context.coroutineScope.launch(Dispatchers.Main) {
+ val database = runCatching {
+ SQLiteDatabase.openDatabase(
+ context.androidContext.getDatabasePath("memories.db"),
+ OpenParams.Builder().build(),
+ )
+ }.getOrNull()
+
+ if (database == null) {
+ context.longToast("Failed to open memories database")
+ return@launch
+ }
+
+ createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
+ ExporterDialog(database) { alertDialog.dismiss() }
+ }.apply {
+ setOnDismissListener { database.close() }
+ setCanceledOnTouchOutside(false)
+ show()
+ }
+ }
+ }
+}+
\ No newline at end of file
diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt
@@ -6,6 +6,7 @@ import me.rhunk.snapenhance.core.ModContext
import me.rhunk.snapenhance.core.action.impl.BulkMessagingAction
import me.rhunk.snapenhance.core.action.impl.CleanCache
import me.rhunk.snapenhance.core.action.impl.ExportChatMessages
+import me.rhunk.snapenhance.core.action.impl.ExportMemories
import me.rhunk.snapenhance.core.manager.Manager
class ActionManager(
@@ -17,6 +18,7 @@ class ActionManager(
EnumAction.CLEAN_CACHE to CleanCache::class,
EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class,
EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction::class,
+ EnumAction.EXPORT_MEMORIES to ExportMemories::class,
).map {
it.key to it.value.java.getConstructor().newInstance().apply {
this.context = modContext
@@ -29,8 +31,8 @@ class ActionManager(
fun onNewIntent(intent: Intent?) {
val action = intent?.getStringExtra(EnumAction.ACTION_PARAMETER) ?: return
- execute(EnumAction.entries.find { it.key == action } ?: return)
intent.removeExtra(EnumAction.ACTION_PARAMETER)
+ execute(EnumAction.entries.find { it.key == action } ?: return)
}
fun execute(enumAction: EnumAction) {