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:
Mapp/proguard-rules.pro | 1+
Mcommon/src/main/assets/lang/en_US.json | 17+++++++++++++++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/action/EnumAction.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/SnapEnhance.kt | 32+++++++++++++++++++++++++++++---
Acore/src/main/kotlin/me/rhunk/snapenhance/core/action/impl/ExportMemories.kt | 385+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/manager/impl/ActionManager.kt | 4+++-
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) {