mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-16 14:17:09 +02:00
feat: export memories
This commit is contained in:
parent
04b70431c7
commit
dd755af5be
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@ -6,5 +6,6 @@
|
|||||||
-keep class org.jf.dexlib2.** { *; }
|
-keep class org.jf.dexlib2.** { *; }
|
||||||
-keep class org.mozilla.javascript.** { *; }
|
-keep class org.mozilla.javascript.** { *; }
|
||||||
-keep class androidx.compose.material.icons.** { *; }
|
-keep class androidx.compose.material.icons.** { *; }
|
||||||
|
-keep class androidx.compose.material3.R$* { *; }
|
||||||
-keep class androidx.navigation.** { *; }
|
-keep class androidx.navigation.** { *; }
|
||||||
-keep class me.rhunk.snapenhance.** { *; }
|
-keep class me.rhunk.snapenhance.** { *; }
|
||||||
|
@ -128,6 +128,7 @@
|
|||||||
"open_map": "Choose location on map",
|
"open_map": "Choose location on map",
|
||||||
"check_for_updates": "Check for updates",
|
"check_for_updates": "Check for updates",
|
||||||
"export_chat_messages": "Export Chat Messages",
|
"export_chat_messages": "Export Chat Messages",
|
||||||
|
"export_memories": "Export Memories",
|
||||||
"bulk_messaging_action": "Bulk Messaging Action"
|
"bulk_messaging_action": "Bulk Messaging Action"
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1075,5 +1076,21 @@
|
|||||||
|
|
||||||
"suspend_location_updates": {
|
"suspend_location_updates": {
|
||||||
"switch_text": "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"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -9,6 +9,7 @@ enum class EnumAction(
|
|||||||
) {
|
) {
|
||||||
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true),
|
CLEAN_CACHE("clean_snapchat_cache", exitOnFinish = true),
|
||||||
EXPORT_CHAT_MESSAGES("export_chat_messages"),
|
EXPORT_CHAT_MESSAGES("export_chat_messages"),
|
||||||
|
EXPORT_MEMORIES("export_memories"),
|
||||||
BULK_MESSAGING_ACTION("bulk_messaging_action");
|
BULK_MESSAGING_ACTION("bulk_messaging_action");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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.NativeUnaryCallEvent
|
||||||
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
|
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
|
||||||
import me.rhunk.snapenhance.core.util.LSPatchUpdater
|
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.HookStage
|
||||||
import me.rhunk.snapenhance.core.util.hook.hook
|
import me.rhunk.snapenhance.core.util.hook.hook
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
|
||||||
@ -36,11 +39,11 @@ class SnapEnhance {
|
|||||||
private lateinit var appContext: ModContext
|
private lateinit var appContext: ModContext
|
||||||
private var isBridgeInitialized = false
|
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 ->
|
Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param ->
|
||||||
val activity = param.thisObject() as Activity
|
val activity = param.thisObject() as Activity
|
||||||
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
|
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
|
||||||
block(activity)
|
block(activity, param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +93,8 @@ class SnapEnhance {
|
|||||||
appContext.mainActivity = this
|
appContext.mainActivity = this
|
||||||
if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hookMainActivity
|
if (isMainActivityNotNull || !appContext.mappings.isMappingsLoaded()) return@hookMainActivity
|
||||||
onActivityCreate()
|
onActivityCreate()
|
||||||
|
jetpackComposeResourceHook()
|
||||||
|
appContext.actionManager.onNewIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
hookMainActivity("onPause") {
|
hookMainActivity("onPause") {
|
||||||
@ -97,6 +102,10 @@ class SnapEnhance {
|
|||||||
appContext.isMainActivityPaused = true
|
appContext.isMainActivityPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hookMainActivity("onNewIntent") { param ->
|
||||||
|
appContext.actionManager.onNewIntent(param.argNullable(0))
|
||||||
|
}
|
||||||
|
|
||||||
var activityWasResumed = false
|
var activityWasResumed = false
|
||||||
//we need to reload the config when the app is resumed
|
//we need to reload the config when the app is resumed
|
||||||
//FIXME: called twice at first launch
|
//FIXME: called twice at first launch
|
||||||
@ -107,7 +116,6 @@ class SnapEnhance {
|
|||||||
return@hookMainActivity
|
return@hookMainActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
appContext.actionManager.onNewIntent(this.intent)
|
|
||||||
appContext.reloadConfig()
|
appContext.reloadConfig()
|
||||||
syncRemote()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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.BulkMessagingAction
|
||||||
import me.rhunk.snapenhance.core.action.impl.CleanCache
|
import me.rhunk.snapenhance.core.action.impl.CleanCache
|
||||||
import me.rhunk.snapenhance.core.action.impl.ExportChatMessages
|
import me.rhunk.snapenhance.core.action.impl.ExportChatMessages
|
||||||
|
import me.rhunk.snapenhance.core.action.impl.ExportMemories
|
||||||
import me.rhunk.snapenhance.core.manager.Manager
|
import me.rhunk.snapenhance.core.manager.Manager
|
||||||
|
|
||||||
class ActionManager(
|
class ActionManager(
|
||||||
@ -17,6 +18,7 @@ class ActionManager(
|
|||||||
EnumAction.CLEAN_CACHE to CleanCache::class,
|
EnumAction.CLEAN_CACHE to CleanCache::class,
|
||||||
EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class,
|
EnumAction.EXPORT_CHAT_MESSAGES to ExportChatMessages::class,
|
||||||
EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction::class,
|
EnumAction.BULK_MESSAGING_ACTION to BulkMessagingAction::class,
|
||||||
|
EnumAction.EXPORT_MEMORIES to ExportMemories::class,
|
||||||
).map {
|
).map {
|
||||||
it.key to it.value.java.getConstructor().newInstance().apply {
|
it.key to it.value.java.getConstructor().newInstance().apply {
|
||||||
this.context = modContext
|
this.context = modContext
|
||||||
@ -29,8 +31,8 @@ class ActionManager(
|
|||||||
|
|
||||||
fun onNewIntent(intent: Intent?) {
|
fun onNewIntent(intent: Intent?) {
|
||||||
val action = intent?.getStringExtra(EnumAction.ACTION_PARAMETER) ?: return
|
val action = intent?.getStringExtra(EnumAction.ACTION_PARAMETER) ?: return
|
||||||
execute(EnumAction.entries.find { it.key == action } ?: return)
|
|
||||||
intent.removeExtra(EnumAction.ACTION_PARAMETER)
|
intent.removeExtra(EnumAction.ACTION_PARAMETER)
|
||||||
|
execute(EnumAction.entries.find { it.key == action } ?: return)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun execute(enumAction: EnumAction) {
|
fun execute(enumAction: EnumAction) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user