Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features)
This commit is contained in:
@@ -113,7 +113,6 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
@@ -166,6 +165,9 @@ android {
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Keep local release builds installable when private release credentials are unavailable.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
debug {
|
||||
@@ -356,6 +358,7 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
properties["debug_flavor"]?.let {
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
@@ -219,19 +222,42 @@ class BridgeService : Service() {
|
||||
triggerScopeSync(SocialScope.getByName(scope), id, true)
|
||||
}
|
||||
|
||||
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
|
||||
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
|
||||
|
||||
override fun passGroupsAndFriends(
|
||||
groups: List<String>,
|
||||
friends: List<String>
|
||||
friends: List<String>,
|
||||
chunkIndex: Int,
|
||||
totalChunks: Int
|
||||
) {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(parsedFriends, parsedGroups)
|
||||
synchronized(friendAccumulator) {
|
||||
if (chunkIndex == 0) {
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
}
|
||||
|
||||
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
|
||||
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
|
||||
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
|
||||
|
||||
if (chunkIndex == totalChunks - 1) {
|
||||
val finalFriends = friendAccumulator.toList()
|
||||
val finalGroups = groupAccumulator.toList()
|
||||
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
|
||||
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(finalFriends, finalGroups)
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
|
||||
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
override fun getScopeNotes(id: String): String? {
|
||||
|
||||
@@ -283,7 +283,7 @@ class DownloadProcessor (
|
||||
while (true) {
|
||||
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
|
||||
|
||||
if (existingFile.length() == inputFile.length()) {
|
||||
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
@@ -376,7 +376,7 @@ class DownloadProcessor (
|
||||
var destFile = File(destDir, fileName)
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
|
||||
|
||||
@@ -151,31 +151,37 @@ class FFMpegProcessor(
|
||||
}
|
||||
|
||||
val outputArguments = ArgumentList().apply {
|
||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
fun applyVideoArguments() {
|
||||
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
when (args.action) {
|
||||
Action.DOWNLOAD_DASH -> {
|
||||
applyVideoArguments()
|
||||
outputArguments += "-ss" to "'${args.startTime}ms'"
|
||||
if (args.duration != null) {
|
||||
outputArguments += "-t" to "'${args.duration}ms'"
|
||||
}
|
||||
}
|
||||
Action.MERGE_OVERLAY -> {
|
||||
applyVideoArguments()
|
||||
inputArguments += "-i" to args.overlay!!.absolutePath
|
||||
outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\""
|
||||
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
||||
}
|
||||
Action.CONVERSION -> {
|
||||
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
outputArguments -= "-c:v"
|
||||
args.videoCodec?.let {
|
||||
applyVideoArguments()
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to it
|
||||
} ?: run {
|
||||
outputArguments += "-vn"
|
||||
@@ -186,6 +192,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
}
|
||||
Action.MERGE_MEDIA -> {
|
||||
applyVideoArguments()
|
||||
inputArguments.clear()
|
||||
val filesInfo = args.inputs.mapNotNull { file ->
|
||||
runCatching {
|
||||
@@ -211,7 +218,7 @@ class FFMpegProcessor(
|
||||
filterSecondPart.append("[v$index][$index:a]")
|
||||
} else {
|
||||
containsNoSound = true
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}]")
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
|
||||
}
|
||||
inputArguments += "-i" to file
|
||||
}
|
||||
@@ -228,9 +235,9 @@ class FFMpegProcessor(
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-map" to "\"[vout]\""
|
||||
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
|
||||
outputArguments += "-map" to "[aout]"
|
||||
outputArguments += "-map" to "[vout]"
|
||||
} finally {
|
||||
filesInfo.forEach { it.second.close() }
|
||||
}
|
||||
@@ -264,8 +271,8 @@ class FFMpegProcessor(
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-filter_complex" to filterParts.toString()
|
||||
outputArguments += "-map" to "[aout]"
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
|
||||
@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -15,7 +17,11 @@ class AppDatabase(
|
||||
val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
lateinit var database: SQLiteDatabase
|
||||
|
||||
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
|
||||
// Multi-subscriber event stream for messaging data updates
|
||||
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
fun executeAsync(block: () -> Unit) {
|
||||
executor.execute {
|
||||
@@ -113,6 +119,22 @@ class AppDatabase(
|
||||
"id CHAR(36) PRIMARY KEY",
|
||||
"content TEXT",
|
||||
),
|
||||
"assistant_registry" to listOf(
|
||||
"id VARCHAR PRIMARY KEY",
|
||||
"kind VARCHAR",
|
||||
"title VARCHAR",
|
||||
"category VARCHAR",
|
||||
"path TEXT",
|
||||
"description TEXT",
|
||||
"settingKey VARCHAR",
|
||||
"screenRoute VARCHAR",
|
||||
"allowedActions TEXT",
|
||||
"allowedValues TEXT",
|
||||
"aliases TEXT",
|
||||
"commonTypos TEXT",
|
||||
"examples TEXT",
|
||||
"searchTokens TEXT",
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import org.json.JSONArray
|
||||
|
||||
data class AssistantRegistryEntry(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val path: String,
|
||||
val description: String,
|
||||
val settingKey: String? = null,
|
||||
val screenRoute: String? = null,
|
||||
val allowedActions: List<String> = emptyList(),
|
||||
val allowedValues: List<String> = emptyList(),
|
||||
val aliases: List<String> = emptyList(),
|
||||
val commonTypos: List<String> = emptyList(),
|
||||
val examples: List<String> = emptyList(),
|
||||
val searchTokens: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
|
||||
|
||||
private fun parseStringList(raw: String?): List<String> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL("DELETE FROM assistant_registry")
|
||||
entries.forEach { entry ->
|
||||
database.insert(
|
||||
"assistant_registry",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("id", entry.id)
|
||||
put("kind", entry.kind)
|
||||
put("title", entry.title)
|
||||
put("category", entry.category)
|
||||
put("path", entry.path)
|
||||
put("description", entry.description)
|
||||
put("settingKey", entry.settingKey)
|
||||
put("screenRoute", entry.screenRoute)
|
||||
put("allowedActions", entry.allowedActions.toJsonArrayString())
|
||||
put("allowedValues", entry.allowedValues.toJsonArrayString())
|
||||
put("aliases", entry.aliases.toJsonArrayString())
|
||||
put("commonTypos", entry.commonTypos.toJsonArrayString())
|
||||
put("examples", entry.examples.toJsonArrayString())
|
||||
put("searchTokens", entry.searchTokens.toJsonArrayString())
|
||||
}
|
||||
)
|
||||
}
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
|
||||
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
|
||||
val entries = mutableListOf<AssistantRegistryEntry>()
|
||||
while (cursor.moveToNext()) {
|
||||
entries += AssistantRegistryEntry(
|
||||
id = cursor.getStringOrNull("id") ?: continue,
|
||||
kind = cursor.getStringOrNull("kind") ?: "feature",
|
||||
title = cursor.getStringOrNull("title") ?: "",
|
||||
category = cursor.getStringOrNull("category") ?: "",
|
||||
path = cursor.getStringOrNull("path") ?: "",
|
||||
description = cursor.getStringOrNull("description") ?: "",
|
||||
settingKey = cursor.getStringOrNull("settingKey"),
|
||||
screenRoute = cursor.getStringOrNull("screenRoute"),
|
||||
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
|
||||
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
|
||||
aliases = parseStringList(cursor.getStringOrNull("aliases")),
|
||||
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
|
||||
examples = parseStringList(cursor.getStringOrNull("examples")),
|
||||
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
|
||||
)
|
||||
}
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.beginTransaction()
|
||||
try {
|
||||
friends.forEach { friend ->
|
||||
// Industrial Filter: Only update existing friends, never auto-insert new ones.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
friend.selfieId,
|
||||
friend.userId
|
||||
)
|
||||
)
|
||||
|
||||
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
// Industrial Filter: Only update existing groups.
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
group.participantsCount,
|
||||
group.conversationId
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
// Notify with the full updated list from the DB
|
||||
val allFriends = getFriends(descOrder = true)
|
||||
val allGroups = getGroups()
|
||||
receiveMessagingDataCallback(allFriends, allGroups)
|
||||
// Notify all observers with the raw sync data (AddFriendDialog needs this)
|
||||
messagingDataFlow.tryEmit(friends to groups)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
title = managerContext.translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
confirmButtonText = managerContext.translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
text = managerContext.translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
|
||||
@@ -269,7 +269,7 @@ fun FloatingTopBar(
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
|
||||
@@ -113,8 +113,8 @@ object Updater {
|
||||
|
||||
fun getLatestRelease(channel: Channel): LatestRelease? {
|
||||
return cache.getOrPut(channel) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
|
||||
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
|
||||
} else {
|
||||
fetchLatestRelease(channel)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,6 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
internal fun getRandomizedProfileSnapshot(): String {
|
||||
context.config.load()
|
||||
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
|
||||
@@ -231,9 +230,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
?: error("Failed to read randomized profile backup")
|
||||
val profile = RandomizedDeviceProfile.fromJson(importedJson)
|
||||
val generationToken = UUID.randomUUID().toString()
|
||||
val profileJson = profile.toJson().toString()
|
||||
|
||||
// Save to local prefs for legacy compatibility
|
||||
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
.edit()
|
||||
.putString("randomized_device_profile", profile.toJson().toString())
|
||||
.putString("randomized_device_profile", profileJson)
|
||||
.putString("randomized_device_profile_token", generationToken)
|
||||
.putString("android_id", profile.androidId)
|
||||
.putString("advertising_id", profile.advertisingId)
|
||||
@@ -246,6 +248,8 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
|
||||
randomizeConfig.profileGenerationToken.set(generationToken)
|
||||
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
|
||||
randomizeConfig.profileData.set(profileJson) // Shared storage fix
|
||||
|
||||
context.config.writeConfig()
|
||||
onConfigChanged()
|
||||
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")
|
||||
|
||||
@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -38,10 +35,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.storage.clearRuleIds
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.storage.setRule
|
||||
@@ -140,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
}
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
|
||||
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getRuleIds(currentRuleType.key)
|
||||
}
|
||||
val currentRuleIdSet = remember(currentRuleIds.size) {
|
||||
currentRuleIds.toSet()
|
||||
}
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -163,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
fun showAddFriendDialog() {
|
||||
addFriendDialog = AddFriendDialog(
|
||||
context = context,
|
||||
pinnedIds = currentRuleIds,
|
||||
pinnedIds = currentRuleIds.toList(),
|
||||
actionHandler = Actions(
|
||||
onFriendState = { friend, state ->
|
||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(friend.userId)
|
||||
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -176,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
currentRuleIdSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
currentRuleIdSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -230,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.zIndex(10f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
.padding(top = topBarHeight + 10.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
item {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()],
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"],
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"] ?: "Disabled",
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
val manageLabel = when (ruleState) {
|
||||
@@ -291,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"] ?: "Whitelist",
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
) {
|
||||
Text(text = translation["whitelist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["dialog_clear_confirm_button"])
|
||||
Text(text = translation["whitelist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"] ?: "Blacklist",
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"] ?: "Clear List",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
class HomeAbout : Routes.Route() {
|
||||
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
},
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
modifier = modifier
|
||||
.height(150.dp)
|
||||
.scaleOnPress(tapSource),
|
||||
interactionSource = tapSource,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp),
|
||||
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
PurrfectMarqueeText(
|
||||
Text(
|
||||
text = name,
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
|
||||
import me.eternal.purrfectsnap.storage.setQuickTiles
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
|
||||
icon: ImageVector,
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.height(36.dp),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White)
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
label?.let {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
@Composable
|
||||
private fun RowScope.HomeActionChips() {
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.DEFAULT
|
||||
)
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"]
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
modifier = Modifier
|
||||
) { routes.homeLogs.navigate() }
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Info,
|
||||
label = translation["manager.routes.home_about"]
|
||||
label = translation["manager.routes.home_about"],
|
||||
modifier = Modifier
|
||||
) { routes.about.navigate() }
|
||||
}
|
||||
|
||||
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
HomeActionChips()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() {
|
||||
internal fun scheduleUpdateCheck() {
|
||||
val workManager = WorkManager.getInstance(context.androidContext)
|
||||
val updateSettings = context.config.root.global.updateSettings
|
||||
var configDirty = false
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.autoUpdateCheck.set(true)
|
||||
true
|
||||
}
|
||||
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateCheckFrequency.set("daily")
|
||||
"daily"
|
||||
}
|
||||
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateChannel.set("stable")
|
||||
"stable"
|
||||
}
|
||||
if (configDirty) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
|
||||
|
||||
if (autoUpdateCheck) {
|
||||
val repeatInterval = when (frequency) {
|
||||
"daily" -> 1L
|
||||
"weekly" -> 7L
|
||||
"monthly" -> 30L
|
||||
else -> 1L
|
||||
}
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
@@ -112,10 +87,10 @@ class HomeSettings : Routes.Route() {
|
||||
.putString("channel_description", translation["update_notification_channel_description"])
|
||||
.putString("notification_title", translation["update_notification_title"])
|
||||
.putString("notification_text", translation["update_notification_text"])
|
||||
.putString("update_channel", updateChannel)
|
||||
.putString("update_channel", "stable")
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("script")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
@@ -26,9 +26,11 @@ import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
import me.eternal.purrfectsnap.storage.getGroups
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
@@ -223,32 +225,34 @@ class AddFriendDialog(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else {
|
||||
this
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
|
||||
val sortedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
// Priority sort for whitelisted groups
|
||||
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
|
||||
sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.conversationId)
|
||||
val bSelected = whitelistedIds.contains(b.conversationId)
|
||||
if (aSelected != bSelected) if (aSelected) -1 else 1
|
||||
else a.name.compareTo(b.name, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
this
|
||||
withContext(Dispatchers.Main) {
|
||||
cachedFriends = sortedFriends
|
||||
cachedGroups = sortedGroups
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
|
||||
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
|
||||
coroutineScope.launch {
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial database load
|
||||
withContext(Dispatchers.IO) {
|
||||
applySnapshot(
|
||||
context.database.getFriends(descOrder = true),
|
||||
@@ -256,20 +260,11 @@ class AddFriendDialog(
|
||||
)
|
||||
}
|
||||
|
||||
context.database.receiveMessagingDataCallback = updateSnapshot
|
||||
// Real-time synchronization flow
|
||||
context.requestSocialSnapshotRefresh()
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
repeat(25) {
|
||||
delay(1000)
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
applySnapshot(dbFriends, dbGroups)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
coroutineScope.launch {
|
||||
context.database.messagingDataFlow.collect { (friends, groups) ->
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +281,6 @@ class AddFriendDialog(
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +340,7 @@ class AddFriendDialog(
|
||||
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
|
||||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
|
||||
} ?: cachedFriends!!
|
||||
|
||||
val selectedFriendCount by remember(filteredFriends) {
|
||||
derivedStateOf {
|
||||
filteredFriends.count { friend ->
|
||||
@@ -356,6 +351,16 @@ class AddFriendDialog(
|
||||
val hasFriendsSelected = selectedFriendCount > 0
|
||||
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
|
||||
|
||||
val selectedGroupCount by remember(filteredGroups) {
|
||||
derivedStateOf {
|
||||
filteredGroups.count { group ->
|
||||
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hasGroupsSelected = selectedGroupCount > 0
|
||||
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
|
||||
|
||||
DialogHeader(searchKeyword)
|
||||
|
||||
LazyColumn(
|
||||
@@ -365,14 +370,54 @@ class AddFriendDialog(
|
||||
) {
|
||||
item {
|
||||
if (filteredGroups.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = true
|
||||
actionHandler.onGroupState(group, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !allGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = false
|
||||
actionHandler.onGroupState(group, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +462,7 @@ class AddFriendDialog(
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
PurrfectPalette.glowSecondary
|
||||
}
|
||||
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
@@ -437,11 +478,7 @@ class AddFriendDialog(
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasFriendsSelected) {
|
||||
PurrfectPalette.glowPrimary
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
}
|
||||
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
|
||||
class LoggedStories : Routes.Route() {
|
||||
override val title: @Composable () -> Unit = {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val text = remember(navBackStackEntry) {
|
||||
navBackStackEntry?.arguments?.getString("id")?.let {
|
||||
context.database.getFriendInfo(it)?.displayName
|
||||
}
|
||||
val userId = navBackStackEntry?.arguments?.getString("id")
|
||||
val displayName by rememberAsyncMutableState(defaultValue = null) {
|
||||
userId?.let { context.database.getFriendInfo(it)?.displayName }
|
||||
}
|
||||
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
|
||||
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
|
||||
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
||||
|
||||
val stories = remember { mutableStateListOf<StoryData>() }
|
||||
val friendInfo = remember { context.database.getFriendInfo(userId) }
|
||||
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
|
||||
context.database.getFriendInfo(userId)
|
||||
}
|
||||
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
|
||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
|
||||
internal fun RemoteSideContext.sortSocialFriends(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
pinnedIds: List<String>? = null
|
||||
): List<MessagingFriendInfo> {
|
||||
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
|
||||
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
|
||||
return friends.sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.userId)
|
||||
val bSelected = whitelistedIds.contains(b.userId)
|
||||
|
||||
if (aSelected != bSelected) {
|
||||
return@sortedWith if (aSelected) -1 else 1
|
||||
}
|
||||
|
||||
if (sortByStreakLength) {
|
||||
val aStreak = a.streaks?.length ?: 0
|
||||
val bStreak = b.streaks?.length ?: 0
|
||||
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
|
||||
}
|
||||
|
||||
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
@@ -53,10 +52,32 @@ class SocialRootSection : Routes.Route() {
|
||||
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
internal fun updateScopeLists() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
@Composable
|
||||
fun SocialDataController() {
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial data fetch from the database
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time synchronization from the bridge
|
||||
context.database.messagingDataFlow.collect {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +145,6 @@ class SocialRootSection : Routes.Route() {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
@@ -158,8 +174,7 @@ class SocialRootSection : Routes.Route() {
|
||||
},
|
||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||
),
|
||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
|
||||
@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
shrinkFactor: Float = 1f,
|
||||
modifier: Modifier = Modifier,
|
||||
expandedWidth: Dp? = null,
|
||||
collapsedWidth: Dp = 36.dp,
|
||||
haptic: HapticFeedback,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
|
||||
Surface(
|
||||
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
|
||||
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(
|
||||
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
|
||||
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
|
||||
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
)
|
||||
if (label != null) {
|
||||
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
|
||||
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = labelAlpha),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
maxLines = 1, overflow = TextOverflow.Clip,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.widthIn(max = (75 * shrinkFactor).dp)
|
||||
)
|
||||
val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
|
||||
if (labelAlpha > 0.02f) {
|
||||
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = labelAlpha),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.weight(1f, fill = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
val shrinkFactor by remember(scrollState.value) {
|
||||
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
|
||||
}
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.APHELION,
|
||||
shrinkFactor = shrinkFactor,
|
||||
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
|
||||
)
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
expandedWidth = 88.dp,
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.homeLogs.navigate() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Settings,
|
||||
label = context.translation["manager.routes.home_settings"],
|
||||
expandedWidth = 96.dp,
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.settings.navigate() }
|
||||
}
|
||||
@@ -226,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
downloadState: UpdateDownloader.DownloadState,
|
||||
downloadProgress: Float,
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onAboutClick: () -> Unit,
|
||||
avenirNext: FontFamily,
|
||||
@@ -283,7 +301,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName))
|
||||
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
|
||||
HeroBadge(translation.format("hero_build_label", "build" to it))
|
||||
}
|
||||
@@ -444,17 +462,17 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
hasInitialized -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
}
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
|
||||
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
|
||||
Updater.getLatestRelease(Channel.STABLE)
|
||||
}
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
@@ -502,7 +520,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
changelogLoading = true
|
||||
changelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val url = changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
@@ -540,7 +558,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val url = changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
@@ -646,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(headerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = Color.White.copy(alpha = stickyBrandingAlpha),
|
||||
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.wrapContentWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
|
||||
haptic = haptic
|
||||
) { showFullChangelogDialog = true; loadFullChangelog() }
|
||||
}
|
||||
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
|
||||
}
|
||||
}
|
||||
@@ -692,7 +700,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onAboutClick = { routes.about.navigate() },
|
||||
avenirNext = avenirNext,
|
||||
|
||||
@@ -47,15 +47,11 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
isRefreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val readerResult = runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
context.log.newReader { /* items are batch-added from reader logic below */ }
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
|
||||
withContext(Dispatchers.Main) { isRefreshing = false }
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
@@ -78,52 +74,63 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
fun LogFilterDialog() {
|
||||
Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
enabledCategories.keys.forEach { enabledCategories[it] = false }
|
||||
enabledCategories[category] = true
|
||||
refreshLogs()
|
||||
PurrfectGlassCard(
|
||||
title = translation["filter_logs_title"] ?: "Log Filters",
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
enabledCategories[category] = !(enabledCategories[category] ?: true)
|
||||
refreshLogs()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.3f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.4f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Done")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
modifier = Modifier.fillMaxWidth().height(54.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.Intent
|
||||
import com.google.gson.JsonParser
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
|
||||
import me.eternal.purrfectsnap.storage.findFriend
|
||||
import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getGroupInfo
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -222,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +263,494 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showExportOptionsDialog by remember { mutableStateOf(false) }
|
||||
var showConversationExportDialog by remember { mutableStateOf(false) }
|
||||
var showConversationFormatDialog by remember { mutableStateOf(false) }
|
||||
var conversationSearchQuery by remember { mutableStateOf("") }
|
||||
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
|
||||
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
|
||||
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
|
||||
|
||||
data class ConversationSearchTarget(
|
||||
val target: LoggerConversationExportTarget,
|
||||
val friendDisplayName: String?,
|
||||
val friendUsername: String?,
|
||||
val chatDisplayName: String?,
|
||||
val groupDisplayName: String?,
|
||||
val readableUsernames: List<String>,
|
||||
val readableIdentifiers: List<String>,
|
||||
val isDirectChat: Boolean,
|
||||
val isGroupChat: Boolean,
|
||||
val sortOrder: Int
|
||||
)
|
||||
|
||||
data class ConversationExportFormat(
|
||||
val extension: String,
|
||||
val mimeType: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class ParsedConversationMessage(
|
||||
val senderId: String,
|
||||
val senderUsername: String,
|
||||
val timestamp: Long,
|
||||
val contentType: ContentType,
|
||||
val messageText: String?,
|
||||
val attachments: List<DecodedAttachment>
|
||||
)
|
||||
|
||||
fun String.isUuidLike(): Boolean {
|
||||
val value = trim()
|
||||
if (value.length != 36) return false
|
||||
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
|
||||
return value.filterIndexed { index, _ ->
|
||||
index != 8 && index != 13 && index != 18 && index != 23
|
||||
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
|
||||
}
|
||||
|
||||
fun String.isLikelyInternalId(): Boolean {
|
||||
val value = trim()
|
||||
if (value.isUuidLike()) return true
|
||||
if (value.length >= 10 && value.all(Char::isDigit)) return true
|
||||
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
|
||||
val digitCount = value.count(Char::isDigit)
|
||||
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
|
||||
if (digitCount >= 4 && alphaCount >= 4) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun String.toReadableIdentityOrNull(): String? {
|
||||
val value = trim()
|
||||
if (value.isEmpty()) return null
|
||||
if (value.isLikelyInternalId()) return null
|
||||
if (!value.any { it.isLetter() }) return null
|
||||
return value
|
||||
}
|
||||
|
||||
fun String.toSearchIdentityOrNull(): String? {
|
||||
val value = trim()
|
||||
if (value.isEmpty()) return null
|
||||
if (value.equals("myai", ignoreCase = true)) return null
|
||||
return value
|
||||
}
|
||||
|
||||
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
|
||||
context.messageLogger.getConversationExportTargets()
|
||||
}
|
||||
val exportSearchTargets by rememberAsyncMutableState(
|
||||
defaultValue = emptyList<ConversationSearchTarget>(),
|
||||
keys = arrayOf(exportTargets)
|
||||
) {
|
||||
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
|
||||
exportTargets.mapIndexedNotNull { index, target ->
|
||||
val friend = context.database.findFriend(target.conversationId)
|
||||
val group = context.database.getGroupInfo(target.conversationId)
|
||||
val chatDisplayName = target.groupTitle
|
||||
?.toReadableIdentityOrNull()
|
||||
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
|
||||
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
|
||||
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
|
||||
val searchableUsernames = target.usernames
|
||||
.mapNotNull { it.toSearchIdentityOrNull() }
|
||||
.distinct()
|
||||
val readableUsernames = searchableUsernames
|
||||
.mapNotNull { it.toReadableIdentityOrNull() }
|
||||
.distinct()
|
||||
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
|
||||
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
|
||||
target.userIds.mapNotNull { userId ->
|
||||
friendIdentityCache.getOrPut(userId) {
|
||||
context.database.getFriendInfo(userId)?.let {
|
||||
it.displayName?.toReadableIdentityOrNull() to
|
||||
it.mutableUsername.toReadableIdentityOrNull()
|
||||
}
|
||||
}?.takeIf { it.first != null || it.second != null }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
|
||||
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
|
||||
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
|
||||
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
|
||||
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
|
||||
?: chatDisplayName?.takeIf { hasManyParticipants }
|
||||
val isGroupChat = groupDisplayName != null || hasManyParticipants
|
||||
val isDirectChat = !isGroupChat
|
||||
val readableIdentifiers = buildList {
|
||||
add(target.conversationId)
|
||||
addAll(target.userIds)
|
||||
resolvedFriendDisplayName?.let { add(it) }
|
||||
resolvedFriendUsername?.let { add(it) }
|
||||
chatDisplayName?.let { add(it) }
|
||||
groupDisplayName?.let { add(it) }
|
||||
addAll(searchableUsernames)
|
||||
addAll(readableUsernames)
|
||||
}.distinct()
|
||||
ConversationSearchTarget(
|
||||
target = target,
|
||||
friendDisplayName = resolvedFriendDisplayName,
|
||||
friendUsername = resolvedFriendUsername,
|
||||
chatDisplayName = chatDisplayName,
|
||||
groupDisplayName = groupDisplayName,
|
||||
readableUsernames = readableUsernames,
|
||||
readableIdentifiers = readableIdentifiers,
|
||||
isDirectChat = isDirectChat,
|
||||
isGroupChat = isGroupChat,
|
||||
sortOrder = index
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy<ConversationSearchTarget> {
|
||||
when {
|
||||
it.isDirectChat -> 0
|
||||
it.isGroupChat -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenBy { it.sortOrder }
|
||||
)
|
||||
}
|
||||
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
|
||||
val query = conversationSearchQuery.trim()
|
||||
if (query.isBlank()) {
|
||||
exportSearchTargets
|
||||
} else {
|
||||
exportSearchTargets.filter { searchTarget ->
|
||||
searchTarget.readableIdentifiers.any {
|
||||
it.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val exportFormats = remember {
|
||||
listOf(
|
||||
ConversationExportFormat("db", "application/octet-stream", ".db"),
|
||||
ConversationExportFormat("html", "text/html", "HTML"),
|
||||
ConversationExportFormat("txt", "text/plain", "TXT")
|
||||
)
|
||||
}
|
||||
|
||||
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
|
||||
searchTarget.friendDisplayName?.let { displayName ->
|
||||
val username = searchTarget.friendUsername
|
||||
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
|
||||
"$displayName • @$username"
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
|
||||
}
|
||||
|
||||
searchTarget.friendUsername?.let { username ->
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
|
||||
}
|
||||
|
||||
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
|
||||
}
|
||||
|
||||
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
|
||||
val friendName = if (searchTarget.readableUsernames.size == 1) {
|
||||
searchTarget.readableUsernames.first()
|
||||
} else {
|
||||
searchTarget.readableUsernames.joinToString(", ")
|
||||
}
|
||||
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
|
||||
}
|
||||
|
||||
searchTarget.groupDisplayName?.let {
|
||||
return loggerHistoryTranslation.format("list_group_format", "name" to it)
|
||||
}
|
||||
|
||||
if (searchTarget.readableUsernames.isNotEmpty()) {
|
||||
return loggerHistoryTranslation.format(
|
||||
"list_group_format",
|
||||
"name" to searchTarget.readableUsernames.joinToString(", ")
|
||||
)
|
||||
}
|
||||
|
||||
return if (searchTarget.isGroupChat) {
|
||||
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
|
||||
} else {
|
||||
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun showExportError(throwable: Throwable) {
|
||||
context.log.error("Failed to export message logger", throwable)
|
||||
context.shortToast(
|
||||
translation.format(
|
||||
"message_logger_export_failed_toast",
|
||||
"message" to (throwable.message ?: "Unknown error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun showImportError(throwable: Throwable) {
|
||||
context.log.error("Failed to import message logger", throwable)
|
||||
context.shortToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (throwable.message ?: "Unknown error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
|
||||
val messageObject = runCatching {
|
||||
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
|
||||
}.getOrNull()
|
||||
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
|
||||
val contentBytes = runCatching {
|
||||
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
|
||||
}.getOrNull()
|
||||
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
|
||||
runCatching { ContentType.valueOf(it) }.getOrNull()
|
||||
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
|
||||
val messageText = contentBytes?.getMessageText(contentType)
|
||||
val attachments = runCatching {
|
||||
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
return ParsedConversationMessage(
|
||||
senderId = message.userId,
|
||||
senderUsername = message.username,
|
||||
timestamp = message.sendTimestamp,
|
||||
contentType = contentType,
|
||||
messageText = messageText,
|
||||
attachments = attachments
|
||||
)
|
||||
}
|
||||
|
||||
fun htmlEscape(input: String): String {
|
||||
val escaped = StringBuilder(input.length)
|
||||
input.forEach { char ->
|
||||
when (char) {
|
||||
'&' -> escaped.append("&")
|
||||
'<' -> escaped.append("<")
|
||||
'>' -> escaped.append(">")
|
||||
'"' -> escaped.append(""")
|
||||
'\'' -> escaped.append("'")
|
||||
else -> escaped.append(char)
|
||||
}
|
||||
}
|
||||
return escaped.toString()
|
||||
}
|
||||
|
||||
fun writeConversationExportFile(
|
||||
target: LoggerConversationExportTarget,
|
||||
format: ConversationExportFormat,
|
||||
outputFile: File
|
||||
): Int {
|
||||
val conversationId = target.conversationId.trim()
|
||||
if (conversationId.isEmpty()) {
|
||||
throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
}
|
||||
|
||||
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
|
||||
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
|
||||
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
|
||||
val dateFormatter = DateFormat.getDateTimeInstance()
|
||||
val senderCache = mutableMapOf<String, String>()
|
||||
|
||||
fun formatSenderLabel(senderId: String, senderUsername: String): String {
|
||||
val friendInfo = context.database.getFriendInfo(senderId)
|
||||
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
|
||||
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
|
||||
?: senderUsername.toReadableIdentityOrNull()
|
||||
return when {
|
||||
senderDisplayName != null &&
|
||||
senderReadableUsername != null &&
|
||||
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
|
||||
"$senderDisplayName (@$senderReadableUsername)"
|
||||
senderDisplayName != null -> senderDisplayName
|
||||
senderReadableUsername != null -> "@$senderReadableUsername"
|
||||
else -> translation["sender_unknown"] ?: "Unknown sender"
|
||||
}
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
if (outputFile.exists() && !outputFile.delete()) {
|
||||
throw IllegalStateException("Failed to prepare export file")
|
||||
}
|
||||
|
||||
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
|
||||
val isHtmlFormat = format.extension == "html"
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<!DOCTYPE html>")
|
||||
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
|
||||
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
|
||||
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
|
||||
writer.appendLine(
|
||||
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
|
||||
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
|
||||
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
|
||||
".content{white-space:pre-wrap;word-break:break-word;}" +
|
||||
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
|
||||
)
|
||||
writer.appendLine("</head><body>")
|
||||
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
|
||||
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
|
||||
} else {
|
||||
writer.appendLine(conversationTitle)
|
||||
writer.appendLine("")
|
||||
}
|
||||
|
||||
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
|
||||
conversationId = conversationId,
|
||||
userIds = target.userIds,
|
||||
orderAscending = true
|
||||
) { loggedMessage ->
|
||||
val parsed = parseConversationMessage(loggedMessage)
|
||||
val senderInfo = senderCache.getOrPut(parsed.senderId) {
|
||||
formatSenderLabel(parsed.senderId, parsed.senderUsername)
|
||||
}
|
||||
val senderLabel = senderInfo
|
||||
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
|
||||
loggerHistoryTranslation["empty_message"]
|
||||
} else {
|
||||
parsed.contentType.name.lowercase()
|
||||
}
|
||||
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<div class=\"message\">")
|
||||
writer.appendLine(
|
||||
"<div class=\"meta\">${
|
||||
htmlEscape(
|
||||
"${dateFormatter.format(Date(parsed.timestamp))} • $senderLabel • ${
|
||||
parsed.contentType.name.lowercase()
|
||||
}"
|
||||
)
|
||||
}</div>"
|
||||
)
|
||||
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
|
||||
if (parsed.attachments.isNotEmpty()) {
|
||||
writer.appendLine("<ul class=\"attachments\">")
|
||||
parsed.attachments.forEachIndexed { index, attachment ->
|
||||
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
|
||||
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
|
||||
if (directUrl != null) {
|
||||
writer.appendLine(
|
||||
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
|
||||
htmlEscape(attachmentLabel)
|
||||
}</a></li>"
|
||||
)
|
||||
} else {
|
||||
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
|
||||
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
|
||||
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
|
||||
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
|
||||
}
|
||||
}
|
||||
writer.appendLine("</ul>")
|
||||
}
|
||||
writer.appendLine("</div>")
|
||||
} else {
|
||||
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
|
||||
parsed.attachments.forEachIndexed { index, attachment ->
|
||||
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
|
||||
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
|
||||
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
|
||||
writer.appendLine(" - $attachmentLabel: $attachmentValue")
|
||||
}
|
||||
writer.appendLine("")
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedMessageCount == 0) {
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
|
||||
} else {
|
||||
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
|
||||
}
|
||||
}
|
||||
|
||||
if (isHtmlFormat) {
|
||||
writer.appendLine("</body></html>")
|
||||
}
|
||||
|
||||
exportedMessageCount
|
||||
}
|
||||
}
|
||||
|
||||
fun exportFullDatabase() {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
|
||||
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
|
||||
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
|
||||
val conversationId = target.conversationId.trim()
|
||||
if (conversationId.isEmpty()) {
|
||||
context.shortToast(translation["message_logger_missing_conversation_toast"])
|
||||
return
|
||||
}
|
||||
|
||||
val fileNameSuffix = conversationId
|
||||
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
.take(24)
|
||||
.ifBlank { "chat" }
|
||||
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val exportedMessageCount = withContext(Dispatchers.IO) {
|
||||
val tempFile = File(
|
||||
context.androidContext.cacheDir,
|
||||
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
|
||||
)
|
||||
try {
|
||||
val messageCount = if (format.extension == "db") {
|
||||
context.messageLogger.exportConversationDatabase(
|
||||
outputFile = tempFile,
|
||||
conversationId = conversationId,
|
||||
userIds = target.userIds
|
||||
).messageCount
|
||||
} else {
|
||||
writeConversationExportFile(target, format, tempFile)
|
||||
}
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
|
||||
tempFile.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
messageCount
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedMessageCount == 0) {
|
||||
context.shortToast(translation["message_logger_empty_chat_toast"])
|
||||
} else {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
}
|
||||
}.onFailure { showExportError(it) }
|
||||
}
|
||||
|
||||
fun dismissConversationExportDialog() {
|
||||
showConversationExportDialog = false
|
||||
selectedConversationForExport = null
|
||||
conversationSearchQuery = ""
|
||||
}
|
||||
|
||||
fun dismissConversationFormatDialog() {
|
||||
showConversationFormatDialog = false
|
||||
pendingConversationExportTarget = null
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
@@ -269,7 +758,222 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = translation["message_logger_import_title"],
|
||||
text = translation["message_logger_import_text"],
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = context.translation["button.import"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val importResult = withContext(Dispatchers.IO) {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
|
||||
context.messageLogger.importDatabase(input)
|
||||
} ?: throw IllegalStateException("Failed to open selected backup file")
|
||||
}
|
||||
storedMessagesCount = importResult.messageCount
|
||||
storedStoriesCount = importResult.storyCount
|
||||
context.shortToast(translation["success_toast"])
|
||||
}.onFailure { showImportError(it) }
|
||||
}
|
||||
}
|
||||
}.onFailure { showImportError(it) }
|
||||
},
|
||||
onDismiss = { showImportDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
if (showExportOptionsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showExportOptionsDialog = false },
|
||||
title = translation["message_logger_export_title"] ?: "Export Message Logger",
|
||||
text = translation["message_logger_export_text"] ?: "Choose what to export.",
|
||||
icon = Icons.Filled.SaveAlt,
|
||||
confirmButtonText = context.translation["button.cancel"],
|
||||
onConfirm = { showExportOptionsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
showExportOptionsDialog = false
|
||||
pendingConversationExportTarget = null
|
||||
showConversationExportDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showExportOptionsDialog = false
|
||||
exportFullDatabase()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showConversationExportDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { dismissConversationExportDialog() },
|
||||
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
|
||||
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
|
||||
icon = Icons.Filled.Search,
|
||||
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onConfirm = {
|
||||
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
|
||||
pendingConversationExportTarget = selectedTarget
|
||||
dismissConversationExportDialog()
|
||||
showConversationFormatDialog = true
|
||||
},
|
||||
onDismiss = { dismissConversationExportDialog() },
|
||||
showCloseButton = false,
|
||||
confirmEnabled = selectedConversationForExport != null,
|
||||
customContent = {
|
||||
OutlinedTextField(
|
||||
value = conversationSearchQuery,
|
||||
onValueChange = { conversationSearchQuery = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
|
||||
{
|
||||
IconButton(onClick = { conversationSearchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
)
|
||||
|
||||
if (filteredExportTargets.isEmpty()) {
|
||||
Text(
|
||||
text = translation["message_logger_no_chats_found"] ?: "No chats found",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 280.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredExportTargets.size) { index ->
|
||||
val searchTarget = filteredExportTargets[index]
|
||||
val target = searchTarget.target
|
||||
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
|
||||
OutlinedButton(
|
||||
onClick = { selectedConversationForExport = searchTarget.target },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) {
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.18f)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = formatExportTarget(searchTarget),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val secondaryLabel = when {
|
||||
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
|
||||
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
|
||||
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
|
||||
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
|
||||
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
|
||||
else -> null
|
||||
}
|
||||
if (secondaryLabel != null) {
|
||||
Text(
|
||||
text = secondaryLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { dismissConversationFormatDialog() },
|
||||
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
|
||||
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = context.translation["button.cancel"],
|
||||
onConfirm = { dismissConversationFormatDialog() },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
exportFormats.forEach { format ->
|
||||
val formatLabel = when (format.extension) {
|
||||
"db" -> translation["message_logger_export_format_db"] ?: ".db"
|
||||
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
|
||||
else -> translation["message_logger_export_format_txt"] ?: "TXT"
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val exportTarget = pendingConversationExportTarget ?: return@Button
|
||||
dismissConversationFormatDialog()
|
||||
exportConversation(exportTarget, format)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(formatLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +39,20 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.sortSocialFriends
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
// Controller handles data loading and synchronization
|
||||
SocialDataController()
|
||||
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
@@ -56,19 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
friendList = friends
|
||||
groupList = groups
|
||||
}
|
||||
updateScopeLists()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
|
||||
// Filter logic based on the parent's synchronized data lists
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("friend_tracker")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.SmartToy
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
|
||||
val setupPrefs = setupContext.sharedPreferences
|
||||
val setupRoutes = Routes(setupContext).apply {
|
||||
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
|
||||
}
|
||||
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
|
||||
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
|
||||
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
|
||||
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
|
||||
|
||||
val requiredScreens = mutableListOf<SetupScreen>().apply {
|
||||
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
|
||||
add(IntroShowcaseScreen().apply { route = "introShowcase" })
|
||||
add(PickLanguageScreen().apply { route = "language" })
|
||||
if (isFirstRunFlow) {
|
||||
add(InstallModeScreen(
|
||||
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
|
||||
AppMaterialTheme {
|
||||
val view = LocalView.current
|
||||
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
var showImportantDialog by rememberSaveable {
|
||||
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
|
||||
}
|
||||
var importantTimeout by remember { mutableIntStateOf(5) }
|
||||
LaunchedEffect(showImportantDialog) {
|
||||
if (showImportantDialog) {
|
||||
importantTimeout = 5
|
||||
while (importantTimeout > 0) {
|
||||
delay(1000)
|
||||
importantTimeout--
|
||||
}
|
||||
}
|
||||
}
|
||||
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
if (showImportantDialog) {
|
||||
val confirmLabel = if (importantTimeout > 0) {
|
||||
translation.format(
|
||||
"setup.activity.important_confirm_timeout",
|
||||
"seconds" to importantTimeout.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.activity.important_confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
title = translation["setup.activity.important_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
confirmEnabled = importantTimeout == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Text(
|
||||
text = translation["setup.activity.important_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
},
|
||||
opaque = true
|
||||
)
|
||||
}
|
||||
SetupAuroraBackground()
|
||||
SetupTopBar()
|
||||
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
|
||||
val bottomPadding = 118.dp + navBarPadding
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 32.dp)
|
||||
)
|
||||
setupAiPrompt?.let { prompt ->
|
||||
ManagerAssistantDialog(
|
||||
context = setupContext,
|
||||
routes = setupRoutes,
|
||||
initialUserMessage = prompt,
|
||||
onDismiss = { setupAiPrompt = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
|
||||
subtitle = translation["setup.activity.language_subtitle"],
|
||||
icon = Icons.Filled.Language
|
||||
)
|
||||
is IntroShowcaseScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Welcome",
|
||||
subtitle = "Preview what PurrfectSnap can do",
|
||||
icon = Icons.Filled.AutoAwesome
|
||||
)
|
||||
|
||||
is InstallModeScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupTopBar() {
|
||||
private fun SetupTopBar(onAskAi: () -> Unit) {
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onAskAi)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = "Ask AI",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package me.eternal.purrfectsnap.ui.setup.screens.impl
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
|
||||
class IntroShowcaseScreen : SetupScreen() {
|
||||
private val slides = listOf(
|
||||
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
|
||||
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
|
||||
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
LaunchedEffect(Unit) { allowNext(true) }
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5000)
|
||||
currentIndex = (currentIndex + 1) % slides.size
|
||||
}
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Welcome to PurrfectSnap",
|
||||
subtitle = "A quick look before setup begins",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(slides[index].first),
|
||||
contentDescription = slides[index].second,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = slides[index].second,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
slides.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 10.dp else 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.setup.patch.LSPatch
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
var installWatcher by remember { mutableStateOf<Job?>(null) }
|
||||
var downloadFinished by rememberSaveable { mutableStateOf(false) }
|
||||
var showIssuesDialog by remember { mutableStateOf(false) }
|
||||
val assistantRoutes = remember {
|
||||
Routes(context).apply {
|
||||
}
|
||||
}
|
||||
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
|
||||
if (showIssuesDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showIssuesDialog = false },
|
||||
title = translation["setup.patch.issues_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["setup.patch.issues_confirm"],
|
||||
onConfirm = { showIssuesDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.patch.issues_heading"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_adb_command"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
softWrap = false,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
ManagerAssistantDialog(
|
||||
context = context,
|
||||
routes = assistantRoutes,
|
||||
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
|
||||
showImprovementLogging = false,
|
||||
onDismiss = { showIssuesDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -431,9 +431,10 @@ class AlertDialogs(
|
||||
DefaultDialogCard {
|
||||
var fieldValue by remember {
|
||||
mutableStateOf(property.value.get().toString().let {
|
||||
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
|
||||
TextFieldValue(
|
||||
text = it,
|
||||
selection = TextRange(it.length)
|
||||
text = t,
|
||||
selection = TextRange(t.length)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -447,10 +448,21 @@ class AlertDialogs(
|
||||
}
|
||||
.focusRequester(focusRequester),
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
keyboardOptions = when (property.key.dataType.type) {
|
||||
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
onValueChange = { newVal ->
|
||||
fieldValue = if (property.key.params.digitsOnlyInput) {
|
||||
val filtered = newVal.text.filter { ch -> ch.isDigit() }
|
||||
if (newVal.text != filtered) {
|
||||
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
newVal.copy(text = filtered)
|
||||
} else {
|
||||
newVal
|
||||
}
|
||||
},
|
||||
keyboardOptions = when {
|
||||
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
},
|
||||
singleLine = true,
|
||||
|
||||
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_kaladin.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 354 KiB |
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
|
||||
}
|
||||
|
||||
// You can still set these for legacy use by submodules or scripts:
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.9").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("325").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.7.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("327").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
## v1.1.0
|
||||
- Fix: Download Context Menu now resolves Message Logger deleted media when available.
|
||||
- Fix: message preview overlap in French.
|
||||
- Fix: transparent gallery media send override dialog.
|
||||
- Fix: message logger autopurge "Never" no longer resets to 3 days.
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
## v1.7.1
|
||||
- Auto-Open Engine ghost notification fix.
|
||||
- Continous Send notifiction bug fix.
|
||||
- Disappering chats fix.
|
||||
- Social page automatic selection bug fix.
|
||||
|
||||
## v1.7.0
|
||||
- Features:
|
||||
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
|
||||
- Implemented app intro showcase (tq ΞTΞRNAL)
|
||||
- Implemented "Spoof follower count" (tq RSR)
|
||||
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
|
||||
- Implemented "Chat Hold Kill" (tq SUJΛL)
|
||||
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
|
||||
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Implemented Memory message icon indicator(tq RSR)
|
||||
- Implemented two new message indicator toggles, for self snaps and group.
|
||||
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
|
||||
- Implemented new notification card for Continous send feature.
|
||||
|
||||
- Fixes:
|
||||
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
|
||||
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
|
||||
- Snapchat Plus bug fixes to improve stability.
|
||||
- Spoof Device profile Backup/Restore bug fixes.
|
||||
- Redesgined manager app Logs filtering UI.
|
||||
- Media Downloader stability and bug fixes.
|
||||
- Performance Mode: Fixed Max Performance mode persistence after app restart.
|
||||
- Chat Preview: Realignment of the chat preview layout.
|
||||
- Update Checker: Refactored the app update checker to follow a single, stable release track with automated daily checks.
|
||||
- Auto-Open engine: Expanded Notification UI has been refactored and redundant stats has been removed.
|
||||
- Social tab: Converted the rules configuration page to a Lazy Column architecture. This eliminates UI freezes when managing large friend lists.
|
||||
- Chat feed scroll stutter: Implemented a thread-safe memory cache for chat suppression logic. This instant memory check allows Snapchat chat feed to scroll at 100% native speed.
|
||||
- GPU Offloading: Forced hardware-layer acceleration for all camera and video preview views to reduce CPU thermal load and power consumption.
|
||||
- Refactored all runBlocking calls and main-thread database queries across 12 major pages to eliminate the UI freezes/stutters and the scrolling, navigation, and page transitions are now locked at high frame rates.
|
||||
- Migrated heavy bridge data processing (JSON parsing and DB writes) to background IO coroutines, preventing ANR (App Not Responding) crashes during large data synchronizations.
|
||||
|
||||
|
||||
## v1.6.9
|
||||
- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta)
|
||||
- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet)
|
||||
|
||||
@@ -71,7 +71,7 @@ interface BridgeInterface {
|
||||
* @param groups list of groups (MessagingGroupInfo as parcelable)
|
||||
* @param friends list of friends (MessagingFriendInfo as parcelable)
|
||||
*/
|
||||
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
|
||||
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends, int chunkIndex, int totalChunks);
|
||||
|
||||
@nullable String getScopeNotes(String id);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -270,6 +270,26 @@
|
||||
"success_toast": "Done!",
|
||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||
"export_button": "Export",
|
||||
"message_logger_export_title": "Export Message Logger",
|
||||
"message_logger_export_text": "Choose what to export.",
|
||||
"message_logger_export_individual_chat": "Export Individual Chat",
|
||||
"message_logger_export_full_database": "Export Full Database",
|
||||
"message_logger_select_chat_title": "Export Individual Chat",
|
||||
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
|
||||
"message_logger_continue_button": "Continue",
|
||||
"message_logger_select_export_format_title": "Select Export Format",
|
||||
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
|
||||
"message_logger_export_format_db": ".db",
|
||||
"message_logger_export_format_html": "HTML",
|
||||
"message_logger_export_format_txt": "TXT",
|
||||
"message_logger_no_chats_found": "No chats found",
|
||||
"message_logger_no_messages_export_text": "No messages found in this chat.",
|
||||
"message_logger_conversation_id": "Conversation ID: {id}",
|
||||
"message_logger_message_count": "{count} messages",
|
||||
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
|
||||
"message_logger_export_failed_toast": "Export failed: {message}",
|
||||
"message_logger_missing_conversation_toast": "Missing conversation ID",
|
||||
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
|
||||
"import_button": "Import",
|
||||
"clear_button": "Clear",
|
||||
"view_logger_history_button": "View Logger History",
|
||||
@@ -1105,6 +1125,10 @@
|
||||
"name": "Show Streak Expiration Info",
|
||||
"description": "Shows a Streak Expiration timer next to the Streaks counter"
|
||||
},
|
||||
"sort_social_tab_by_streak_length": {
|
||||
"name": "Sort Social Tab by Streak Length",
|
||||
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
|
||||
},
|
||||
"hide_friend_feed_entry": {
|
||||
"name": "Hide Friend Feed Entry",
|
||||
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
|
||||
@@ -2314,6 +2338,8 @@
|
||||
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
|
||||
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
|
||||
"stealth": "\ud83d\udc7b Full Stealth Mode",
|
||||
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
|
||||
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
|
||||
"auto_reply": "\ud83d\udce8 Auto Reply",
|
||||
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
|
||||
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
|
||||
@@ -2641,7 +2667,10 @@
|
||||
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
|
||||
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
|
||||
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
|
||||
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
|
||||
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "Mark conversation as read when sending a message",
|
||||
|
||||
@@ -197,12 +197,12 @@
|
||||
},
|
||||
"sections": {
|
||||
"home": {
|
||||
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
|
||||
"version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
|
||||
"update_title": "PurrfectSnap Update",
|
||||
"update_content": "Version {version} is available!",
|
||||
"update_button": "Download",
|
||||
"hero_tagline": "An Xposed Module meant to enhance your Snapchat experience",
|
||||
"hero_version_label": "Version: {version} - {channel}",
|
||||
"hero_version_label": "Version: {version}",
|
||||
"hero_build_label": "Build: {build}",
|
||||
"update_ready_label": "Ready to install",
|
||||
"purr_aura_active_label": "PurrAura Active!",
|
||||
@@ -247,9 +247,9 @@
|
||||
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
|
||||
"about_lead_developers_title": "Lead Developers",
|
||||
"about_story_title": "Our Story",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_thanks_title": "With love, PurrfectSnap Team",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
|
||||
"github_button": "GitHub",
|
||||
"telegram_button": "Telegram"
|
||||
},
|
||||
@@ -281,6 +281,26 @@
|
||||
"success_toast": "Done!",
|
||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||
"export_button": "Export",
|
||||
"message_logger_export_title": "Export Message Logger",
|
||||
"message_logger_export_text": "Choose what to export.",
|
||||
"message_logger_export_individual_chat": "Export Individual Chat",
|
||||
"message_logger_export_full_database": "Export Full Database",
|
||||
"message_logger_select_chat_title": "Export Individual Chat",
|
||||
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
|
||||
"message_logger_continue_button": "Continue",
|
||||
"message_logger_select_export_format_title": "Select Export Format",
|
||||
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
|
||||
"message_logger_export_format_db": ".db",
|
||||
"message_logger_export_format_html": "HTML",
|
||||
"message_logger_export_format_txt": "TXT",
|
||||
"message_logger_no_chats_found": "No chats found",
|
||||
"message_logger_no_messages_export_text": "No messages found in this chat.",
|
||||
"message_logger_conversation_id": "Conversation ID: {id}",
|
||||
"message_logger_message_count": "{count} messages",
|
||||
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
|
||||
"message_logger_export_failed_toast": "Export failed: {message}",
|
||||
"message_logger_missing_conversation_toast": "Missing conversation ID",
|
||||
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
|
||||
"import_button": "Import",
|
||||
"clear_button": "Clear",
|
||||
"view_logger_history_button": "View Logger History",
|
||||
@@ -357,21 +377,22 @@
|
||||
"remove_all_tasks_confirm": "Remove all tasks?"
|
||||
},
|
||||
"features": {
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"disabled": "Disabled",
|
||||
"export_option": "Export",
|
||||
"import_option": "Import",
|
||||
"reset_option": "Reset",
|
||||
"config_export_success_toast": "Config exported successfully",
|
||||
"config_import_success_toast": "Config imported successfully",
|
||||
"config_import_failure_toast": "Failed to import config {error}",
|
||||
"config_export_failure_toast": "Failed to export config {error}",
|
||||
"saved_config_snackbar": "Config saved",
|
||||
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
|
||||
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
|
||||
"search_button": "Search",
|
||||
"search_results_count": "{count} messages",
|
||||
"clear_history": "Clear search history",
|
||||
"subtitle": "Explore and manage premium features"
|
||||
"subtitle": "Explore and manage premium features",
|
||||
"digits_only_toast": "Only numbers are allowed."
|
||||
},
|
||||
"bypass_status": {
|
||||
"active": "PurrAura Active",
|
||||
@@ -1156,6 +1177,10 @@
|
||||
"name": "Show Streak Expiration Info",
|
||||
"description": "Shows a Streak Expiration timer next to the Streaks counter"
|
||||
},
|
||||
"sort_social_tab_by_streak_length": {
|
||||
"name": "Sort Social Tab by Streak Length",
|
||||
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
|
||||
},
|
||||
"hide_friend_feed_entry": {
|
||||
"name": "Hide Friend Feed Entry",
|
||||
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
|
||||
@@ -1253,6 +1278,16 @@
|
||||
"description": "The custom Snap Score you want to display (max 9,999,999)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_followers_count": {
|
||||
"name": "Spoof Followers Count",
|
||||
"description": "Spoof your follower count on your profile (local only).",
|
||||
"properties": {
|
||||
"custom_followers_count": {
|
||||
"name": "Custom Followers Count",
|
||||
"description": "Number to show (digits only)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1510,7 +1545,10 @@
|
||||
"name": "Bypass Message Action Restrictions",
|
||||
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
|
||||
},
|
||||
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
|
||||
"pre_fetch_snaps": {
|
||||
"name": "Snap Pre-Fetch",
|
||||
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
|
||||
},
|
||||
"remove_groups_locked_status": {
|
||||
"name": "Remove Groups Locked Status",
|
||||
"description": "Allows you to view group information after being kicked"
|
||||
@@ -1731,9 +1769,12 @@
|
||||
},
|
||||
"thermal_protection": {
|
||||
"name": "Thermal Protection",
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
|
||||
},
|
||||
"only_on_wifi": {
|
||||
"name": "Auto Open only on Wi-Fi",
|
||||
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
|
||||
},
|
||||
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
|
||||
"content_type_snap": "Snap",
|
||||
"only_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
@@ -1743,41 +1784,9 @@
|
||||
"name": "Auto Open Scheduler",
|
||||
"description": "Define the start and end times for scheduled throttled processing."
|
||||
},
|
||||
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
|
||||
}
|
||||
},
|
||||
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
|
||||
"instant_translation": {
|
||||
"name": "Message Translator",
|
||||
"description": "Configure the message translator"
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
"safe_processing": {
|
||||
"name": "Auto Open with stealth pace",
|
||||
"description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1835,6 +1844,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
"name": "Auto Delete Sent Messages",
|
||||
"description": "Automatically deletes sent messages after a specified time period",
|
||||
"properties": {
|
||||
"allow_running_in_background": {
|
||||
"name": "Allow Running in Background",
|
||||
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
|
||||
},
|
||||
"delete_after_value": {
|
||||
"name": "Delete After (value)",
|
||||
"description": "Time value before deleting the sent message"
|
||||
},
|
||||
"delete_after_unit": {
|
||||
"name": "Time Unit",
|
||||
"description": "Select the time unit for deletion delay"
|
||||
},
|
||||
"message_types": {
|
||||
"name": "Message Types",
|
||||
"description": "Select which message types should be auto-deleted"
|
||||
},
|
||||
"show_countdown": {
|
||||
"name": "Show Countdown",
|
||||
"description": "Show countdown before deleting the message"
|
||||
},
|
||||
"show_notification": {
|
||||
"name": "Show Notification",
|
||||
"description": "Show notification during countdown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scheduled_send_allow_running_in_background": {
|
||||
"name": "Allow Scheduled Send to Run in Background",
|
||||
"description": "Keep scheduled messages processing while Snapchat is in the background"
|
||||
@@ -2120,7 +2159,15 @@
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
|
||||
"camera_tweaks": {
|
||||
"name": "Upgraded Camera Engine",
|
||||
"description": "Enables professional hardware ISP processing modes for better dynamic range"
|
||||
},
|
||||
"audio_video": {
|
||||
"name": "Upgraded Audio and Video",
|
||||
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "Video Recording Timer",
|
||||
"description": "Shows a recording timer overlay when recording video"
|
||||
},
|
||||
@@ -2690,7 +2737,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": {
|
||||
"network_optimization": {
|
||||
"name": "Improved Network Connectivity",
|
||||
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
|
||||
},
|
||||
"better_transcript": {
|
||||
"name": "Better Transcript",
|
||||
"description": "Improves the voice note transcript",
|
||||
"properties": {
|
||||
@@ -2890,6 +2941,8 @@
|
||||
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
|
||||
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
|
||||
"stealth": "\ud83d\udc7b Full Stealth Mode",
|
||||
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
|
||||
"chat_stealth": "\ud83d\udcac Chat Stealth Mode",
|
||||
"auto_reply": "\ud83d\udce8 Auto Reply",
|
||||
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
|
||||
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
|
||||
@@ -3251,7 +3304,10 @@
|
||||
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
|
||||
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
|
||||
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
|
||||
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
|
||||
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
|
||||
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "Mark conversation as read when sending a message",
|
||||
@@ -3832,7 +3888,12 @@
|
||||
"export_failed_toast": "Failed to export account. Check logs for more info.",
|
||||
"forced_logout_toast": "Removed account due to forced logout"
|
||||
},
|
||||
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_snaps": {
|
||||
"title": "Auto Open Snaps",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"priority_title": "Auto Open Snaps (Priority)",
|
||||
"auto_open_schedule": {
|
||||
"title": "Auto Open Scheduler",
|
||||
"start": "Start",
|
||||
@@ -3849,7 +3910,6 @@
|
||||
"action_pause": "Pause",
|
||||
"action_resume": "Resume",
|
||||
"action_clear": "Clear Queue",
|
||||
"action_reset": "Reset Count",
|
||||
"error_content": "Failed to open snap from {sender}: {error}",
|
||||
"resumed_feedback": "Auto Open Resumed",
|
||||
"paused_feedback": "Auto Open Paused",
|
||||
@@ -3866,9 +3926,9 @@
|
||||
"speed_throttled": "Throttled",
|
||||
"estimated_time": "Estimated Time",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_total_opened": "Lifetime Opened",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_no_snaps_queue": "Monitoring snaps in background...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"queue_cleared": "Queue cleared and statistics reset",
|
||||
"queue_cleared_title": "Queue cleared",
|
||||
"queue_cleared_reset": "Queue Cleared & Reset",
|
||||
@@ -3883,12 +3943,8 @@
|
||||
"conversation_type_group_chat": "Group Chat",
|
||||
"conversation_type_chat": "Chat",
|
||||
"notification_status": "Status",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_queue_size": "Queue Size",
|
||||
"notification_total_opened": "Total Snaps Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_processing_continue": "Processing will continue automatically...",
|
||||
"notification_no_snaps_queue": "No snaps in queue.",
|
||||
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
|
||||
"content_type_photo_video_snap": "Photo/Video Snap",
|
||||
"conversation_type_group_with_name": "Group: {name}",
|
||||
@@ -4016,7 +4072,7 @@
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"posted_on": "Posted",
|
||||
"loading_username": "Loading…",
|
||||
"loading_username": "Loading\u2026",
|
||||
"username_copied": "Username copied",
|
||||
"user_id_copied": "User ID copied",
|
||||
"friend_status": "Friend status",
|
||||
@@ -4101,10 +4157,10 @@
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
},
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"search_by": "Search by",
|
||||
"since": "Since",
|
||||
"until": "Until",
|
||||
@@ -4311,8 +4367,6 @@
|
||||
"added": "Added",
|
||||
"no_friends_found": "No friends found",
|
||||
"no_messages": "No messages",
|
||||
"message": "Message",
|
||||
"type_message": "Type message...",
|
||||
"exporting_memories": "Exporting memories... ({failed} failed)"
|
||||
},
|
||||
"clear_friend_feed": "Clear Friend Feed",
|
||||
@@ -4457,4 +4511,4 @@
|
||||
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
|
||||
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
|
||||
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
|
||||
}
|
||||
}
|
||||
@@ -2544,7 +2544,10 @@
|
||||
"platform_indicator": "वह प्लेटफ़ॉर्म आइकन जोड़ता है जहाँ से मीडिया भेजा गया था (उदा. Android, iOS, Web)",
|
||||
"location_indicator": "Snaps में \ud83d\udccd आइकन जोड़ता है जब उन्हें लोकेशन सक्षम के साथ भेजा गया हो",
|
||||
"ovf_editor_indicator": "इंगित करता है कि क्या कोई Snap OVF एडिटर का उपयोग करके भेजा गया है",
|
||||
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है"
|
||||
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है",
|
||||
"memories_indicator": "मेमोरीज़ से फिर से भेजे गए Snaps में \ud83d\udcd6 आइकन जोड़ता है",
|
||||
"skip_own_indicators": "अपने स्वयं के भेजे गए Snaps पर संकेतक आइकन छुपाता है \ud83d\udc64",
|
||||
"disable_indicators_in_groups": "UI अव्यवस्था को कम करने के लिए समूह वार्तालापों में सभी संकेतकों को अक्षम करता है \ud83d\udc65"
|
||||
},
|
||||
"auto_mark_as_read": {
|
||||
"conversation_read": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें",
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import com.google.gson.GsonBuilder
|
||||
@@ -20,6 +21,7 @@ import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
class LoggedMessage(
|
||||
@@ -70,10 +72,74 @@ data class TrackerLog(
|
||||
}
|
||||
}
|
||||
|
||||
data class LoggerConversationExportTarget(
|
||||
val conversationId: String,
|
||||
val groupTitle: String?,
|
||||
val usernames: List<String>,
|
||||
val userIds: List<String>,
|
||||
val messageCount: Int
|
||||
)
|
||||
|
||||
data class ConversationExportResult(
|
||||
val messageCount: Int,
|
||||
val chatEditCount: Int,
|
||||
val trackerEventCount: Int
|
||||
)
|
||||
|
||||
data class DatabaseImportResult(
|
||||
val messageCount: Int,
|
||||
val storyCount: Int
|
||||
)
|
||||
|
||||
class LoggerWrapper(
|
||||
val databaseFile: File,
|
||||
private val readOnly: Boolean = false
|
||||
): LoggerInterface.Stub() {
|
||||
companion object {
|
||||
private val MESSAGE_LOGGER_SCHEMA = mapOf(
|
||||
"messages" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"message_id BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"user_id CHAR(36)",
|
||||
"username VARCHAR",
|
||||
"send_timestamp BIGINT",
|
||||
"added_timestamp BIGINT",
|
||||
"group_title VARCHAR",
|
||||
"message_data BLOB"
|
||||
),
|
||||
"chat_edits" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"edit_number INTEGER",
|
||||
"added_timestamp BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"message_id BIGINT",
|
||||
"message_text BLOB"
|
||||
),
|
||||
"stories" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"added_timestamp BIGINT",
|
||||
"user_id VARCHAR",
|
||||
"posted_timestamp BIGINT",
|
||||
"created_timestamp BIGINT",
|
||||
"url VARCHAR",
|
||||
"encryption_key BLOB",
|
||||
"encryption_iv BLOB"
|
||||
),
|
||||
"tracker_events" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"timestamp BIGINT",
|
||||
"conversation_id CHAR(36)",
|
||||
"conversation_title VARCHAR",
|
||||
"is_group BOOLEAN",
|
||||
"username VARCHAR",
|
||||
"user_id VARCHAR",
|
||||
"event_type VARCHAR",
|
||||
"data VARCHAR"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
constructor(context: Context, uri: Uri? = null): this(
|
||||
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
|
||||
uri != null
|
||||
@@ -84,68 +150,190 @@ class LoggerWrapper(
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
|
||||
private val gson by lazy { GsonBuilder().create() }
|
||||
|
||||
private fun openDatabase(file: File, readOnly: Boolean): SQLiteDatabase {
|
||||
val dbFlags = if (readOnly) {
|
||||
SQLiteDatabase.OPEN_READONLY
|
||||
} else {
|
||||
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
}
|
||||
return SQLiteDatabase.openDatabase(file.absolutePath, null, dbFlags).also { openedDatabase ->
|
||||
if (!readOnly) {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDatabaseLocked() {
|
||||
_database?.takeIf { it.isOpen }?.close()
|
||||
_database = null
|
||||
}
|
||||
|
||||
private val database get() = synchronized(this) {
|
||||
_database?.takeIf { it.isOpen } ?: run {
|
||||
_database?.close()
|
||||
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
|
||||
if (!readOnly) {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
|
||||
"messages" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"message_id BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"user_id CHAR(36)",
|
||||
"username VARCHAR",
|
||||
"send_timestamp BIGINT",
|
||||
"added_timestamp BIGINT",
|
||||
"group_title VARCHAR",
|
||||
"message_data BLOB"
|
||||
),
|
||||
"chat_edits" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"edit_number INTEGER",
|
||||
"added_timestamp BIGINT",
|
||||
"conversation_id VARCHAR",
|
||||
"message_id BIGINT",
|
||||
"message_text BLOB"
|
||||
),
|
||||
"stories" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"added_timestamp BIGINT",
|
||||
"user_id VARCHAR",
|
||||
"posted_timestamp BIGINT",
|
||||
"created_timestamp BIGINT",
|
||||
"url VARCHAR",
|
||||
"encryption_key BLOB",
|
||||
"encryption_iv BLOB"
|
||||
),
|
||||
"tracker_events" to listOf(
|
||||
"id INTEGER PRIMARY KEY",
|
||||
"timestamp BIGINT",
|
||||
"conversation_id CHAR(36)",
|
||||
"conversation_title VARCHAR",
|
||||
"is_group BOOLEAN",
|
||||
"username VARCHAR",
|
||||
"user_id VARCHAR",
|
||||
"event_type VARCHAR",
|
||||
"data VARCHAR"
|
||||
)
|
||||
))
|
||||
}
|
||||
closeDatabaseLocked()
|
||||
val openedDatabase = openDatabase(databaseFile, readOnly)
|
||||
_database = openedDatabase
|
||||
openedDatabase
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
_database?.close()
|
||||
synchronized(this) {
|
||||
closeDatabaseLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
|
||||
}
|
||||
|
||||
private fun resolveDatabaseSidecars(file: File): List<File> {
|
||||
return listOf(
|
||||
file,
|
||||
File("${file.absolutePath}-wal"),
|
||||
File("${file.absolutePath}-shm"),
|
||||
File("${file.absolutePath}-journal")
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteIfExists(file: File) {
|
||||
if (file.exists() && !file.delete()) {
|
||||
throw IllegalStateException("Failed to delete ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun replaceFile(source: File, target: File) {
|
||||
if (!source.exists()) {
|
||||
throw IllegalStateException("Missing source file ${source.name}")
|
||||
}
|
||||
if (source.renameTo(target)) return
|
||||
source.inputStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
if (!source.delete()) {
|
||||
throw IllegalStateException("Failed to delete temporary file ${source.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireCompatibleSchema(db: SQLiteDatabase) {
|
||||
MESSAGE_LOGGER_SCHEMA.forEach { (tableName, columns) ->
|
||||
val existingColumns = mutableListOf<String>()
|
||||
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val columnName = cursor.getStringOrNull("name") ?: continue
|
||||
val columnType = cursor.getStringOrNull("type") ?: ""
|
||||
existingColumns.add("$columnName $columnType".trim())
|
||||
}
|
||||
}
|
||||
if (existingColumns.isEmpty()) {
|
||||
throw IllegalStateException("Selected database is missing required table $tableName")
|
||||
}
|
||||
|
||||
val missingColumns = columns.filter { expectedColumn ->
|
||||
!expectedColumn.uppercase().startsWith("PRIMARY KEY") &&
|
||||
existingColumns.none { existingColumn -> expectedColumn.startsWith(existingColumn) }
|
||||
}
|
||||
if (missingColumns.isNotEmpty()) {
|
||||
throw IllegalStateException(
|
||||
"Selected database has incompatible schema for $tableName: ${missingColumns.joinToString()}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireIntegrity(db: SQLiteDatabase) {
|
||||
val integrityResult = db.rawQuery("PRAGMA integrity_check(1)", null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else cursor.getString(0)
|
||||
}
|
||||
if (integrityResult == null || !integrityResult.equals("ok", ignoreCase = true)) {
|
||||
throw IllegalStateException("Selected database failed integrity check: ${integrityResult ?: "unknown"}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTableCount(db: SQLiteDatabase, tableName: String): Int {
|
||||
return db.rawQuery("SELECT COUNT(*) FROM $tableName", null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) 0 else cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun importDatabase(inputStream: InputStream): DatabaseImportResult {
|
||||
if (readOnly) {
|
||||
throw IllegalStateException("Cannot import into read-only logger")
|
||||
}
|
||||
|
||||
val databaseDir = databaseFile.parentFile
|
||||
?: throw IllegalStateException("Cannot resolve message logger database directory")
|
||||
if (!databaseDir.exists() && !databaseDir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create message logger directory")
|
||||
}
|
||||
|
||||
val tempFile = File(
|
||||
databaseDir,
|
||||
"${databaseFile.name}.import-${System.currentTimeMillis()}-${UUID.randomUUID()}"
|
||||
)
|
||||
val backupFile = File(
|
||||
databaseDir,
|
||||
"${databaseFile.name}.backup-${System.currentTimeMillis()}-${UUID.randomUUID()}"
|
||||
)
|
||||
|
||||
try {
|
||||
inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val copiedBytes = input.copyTo(output)
|
||||
if (copiedBytes <= 0L) {
|
||||
throw IllegalStateException("Selected backup is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openDatabase(tempFile, readOnly = true).use { importedDatabase ->
|
||||
requireIntegrity(importedDatabase)
|
||||
requireCompatibleSchema(importedDatabase)
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
closeDatabaseLocked()
|
||||
val hadExistingDatabase = databaseFile.exists()
|
||||
if (hadExistingDatabase) {
|
||||
databaseFile.inputStream().use { input ->
|
||||
backupFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
|
||||
replaceFile(tempFile, databaseFile)
|
||||
resolveDatabaseSidecars(databaseFile).filter { it != databaseFile }.forEach(::deleteIfExists)
|
||||
|
||||
val reopenedDatabase = openDatabase(databaseFile, readOnly = false)
|
||||
val importResult = DatabaseImportResult(
|
||||
messageCount = getTableCount(reopenedDatabase, "messages"),
|
||||
storyCount = getTableCount(reopenedDatabase, "stories")
|
||||
)
|
||||
_database = reopenedDatabase
|
||||
deleteIfExists(backupFile)
|
||||
return importResult
|
||||
} catch (throwable: Throwable) {
|
||||
runCatching {
|
||||
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
|
||||
if (backupFile.exists()) {
|
||||
replaceFile(backupFile, databaseFile)
|
||||
}
|
||||
}
|
||||
closeDatabaseLocked()
|
||||
throw throwable
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { deleteIfExists(tempFile) }
|
||||
runCatching { deleteIfExists(backupFile) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray {
|
||||
if (conversationId.any {
|
||||
runCatching { UUID.fromString(it) }.isFailure
|
||||
@@ -425,6 +613,223 @@ class LoggerWrapper(
|
||||
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
|
||||
}
|
||||
|
||||
fun getConversationExportTargets(): List<LoggerConversationExportTarget> {
|
||||
val groupedConversations = mutableListOf<Triple<String, String?, Int>>()
|
||||
database.rawQuery(
|
||||
"SELECT conversation_id, MAX(group_title) AS group_title, COUNT(*) AS message_count, MAX(send_timestamp) AS last_timestamp " +
|
||||
"FROM messages WHERE conversation_id IS NOT NULL AND TRIM(conversation_id) != '' " +
|
||||
"GROUP BY conversation_id ORDER BY last_timestamp DESC",
|
||||
null
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val conversationId = cursor.getStringOrNull("conversation_id")?.takeIf { it.isNotBlank() } ?: continue
|
||||
groupedConversations.add(
|
||||
Triple(
|
||||
conversationId,
|
||||
cursor.getStringOrNull("group_title"),
|
||||
cursor.getIntOrNull("message_count") ?: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return groupedConversations.map { (conversationId, groupTitle, messageCount) ->
|
||||
val userIds = linkedSetOf<String>()
|
||||
val usernames = linkedSetOf<String>()
|
||||
database.rawQuery(
|
||||
"SELECT DISTINCT user_id, username FROM messages WHERE conversation_id = ?",
|
||||
arrayOf(conversationId)
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { userIds.add(it) }
|
||||
cursor.getStringOrNull("username")?.takeIf { it.isNotBlank() }?.let { usernames.add(it) }
|
||||
}
|
||||
}
|
||||
LoggerConversationExportTarget(
|
||||
conversationId = conversationId,
|
||||
groupTitle = groupTitle,
|
||||
usernames = usernames.toList(),
|
||||
userIds = userIds.toList(),
|
||||
messageCount = messageCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportConversationDatabase(
|
||||
outputFile: File,
|
||||
conversationId: String,
|
||||
userIds: Collection<String> = emptyList()
|
||||
): ConversationExportResult {
|
||||
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
|
||||
?: throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
val normalizedUserIds = userIds
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.toSet()
|
||||
.toMutableSet()
|
||||
.also { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
database.rawQuery(
|
||||
"SELECT DISTINCT user_id FROM messages WHERE conversation_id = ? AND user_id IS NOT NULL AND TRIM(user_id) != ''",
|
||||
arrayOf(normalizedConversationId)
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { ids.add(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
if (outputFile.exists() && !outputFile.delete()) {
|
||||
throw IllegalStateException("Failed to prepare export file")
|
||||
}
|
||||
|
||||
val outputDatabase = SQLiteDatabase.openDatabase(
|
||||
outputFile.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||
)
|
||||
var transactionStarted = false
|
||||
try {
|
||||
SQLiteDatabaseHelper.createTablesFromSchema(outputDatabase, MESSAGE_LOGGER_SCHEMA)
|
||||
outputDatabase.beginTransaction()
|
||||
transactionStarted = true
|
||||
|
||||
val messageWhereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val messageWhereArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
|
||||
val messageCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM messages WHERE $messageWhereClause ORDER BY send_timestamp ASC",
|
||||
sourceArgs = messageWhereArgs,
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "messages"
|
||||
)
|
||||
|
||||
val chatEditCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM chat_edits WHERE conversation_id = ? AND message_id IN (SELECT message_id FROM messages WHERE $messageWhereClause) ORDER BY added_timestamp ASC",
|
||||
sourceArgs = arrayOf(normalizedConversationId, *messageWhereArgs),
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "chat_edits"
|
||||
)
|
||||
|
||||
val trackerWhereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val trackerArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
val trackerEventCount = copyQueryRows(
|
||||
sourceQuery = "SELECT * FROM tracker_events WHERE $trackerWhereClause ORDER BY timestamp ASC",
|
||||
sourceArgs = trackerArgs,
|
||||
targetDatabase = outputDatabase,
|
||||
targetTable = "tracker_events"
|
||||
)
|
||||
|
||||
outputDatabase.setTransactionSuccessful()
|
||||
return ConversationExportResult(
|
||||
messageCount = messageCount,
|
||||
chatEditCount = chatEditCount,
|
||||
trackerEventCount = trackerEventCount
|
||||
)
|
||||
} finally {
|
||||
if (transactionStarted) {
|
||||
outputDatabase.endTransaction()
|
||||
}
|
||||
outputDatabase.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cursorToContentValues(cursor: Cursor): ContentValues {
|
||||
return ContentValues(cursor.columnCount).apply {
|
||||
for (columnIndex in 0 until cursor.columnCount) {
|
||||
val columnName = cursor.getColumnName(columnIndex)
|
||||
when (cursor.getType(columnIndex)) {
|
||||
Cursor.FIELD_TYPE_NULL -> putNull(columnName)
|
||||
Cursor.FIELD_TYPE_INTEGER -> put(columnName, cursor.getLong(columnIndex))
|
||||
Cursor.FIELD_TYPE_FLOAT -> put(columnName, cursor.getDouble(columnIndex))
|
||||
Cursor.FIELD_TYPE_STRING -> put(columnName, cursor.getString(columnIndex))
|
||||
Cursor.FIELD_TYPE_BLOB -> put(columnName, cursor.getBlob(columnIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyQueryRows(
|
||||
sourceQuery: String,
|
||||
sourceArgs: Array<String>? = null,
|
||||
targetDatabase: SQLiteDatabase,
|
||||
targetTable: String
|
||||
): Int {
|
||||
var rowCount = 0
|
||||
database.rawQuery(sourceQuery, sourceArgs).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
targetDatabase.insert(targetTable, null, cursorToContentValues(cursor))
|
||||
rowCount++
|
||||
}
|
||||
}
|
||||
return rowCount
|
||||
}
|
||||
|
||||
private fun cursorToLoggedMessage(cursor: Cursor): LoggedMessage? {
|
||||
return LoggedMessage(
|
||||
messageId = cursor.getLongOrNull("message_id") ?: return null,
|
||||
conversationId = cursor.getStringOrNull("conversation_id") ?: return null,
|
||||
userId = cursor.getStringOrNull("user_id") ?: return null,
|
||||
username = cursor.getStringOrNull("username") ?: return null,
|
||||
sendTimestamp = cursor.getLongOrNull("send_timestamp") ?: return null,
|
||||
addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return null,
|
||||
groupTitle = cursor.getStringOrNull("group_title"),
|
||||
messageData = cursor.getBlobOrNull("message_data") ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun forEachConversationMessage(
|
||||
conversationId: String,
|
||||
userIds: Collection<String> = emptyList(),
|
||||
orderAscending: Boolean = true,
|
||||
block: (LoggedMessage) -> Unit
|
||||
): Int {
|
||||
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
|
||||
?: throw IllegalArgumentException("Conversation ID cannot be empty")
|
||||
val normalizedUserIds = userIds
|
||||
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
|
||||
.toSet()
|
||||
|
||||
val whereClause = buildString {
|
||||
append("conversation_id = ?")
|
||||
if (normalizedUserIds.isNotEmpty()) {
|
||||
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
|
||||
}
|
||||
}
|
||||
val whereArgs = mutableListOf(normalizedConversationId).apply {
|
||||
addAll(normalizedUserIds)
|
||||
}.toTypedArray()
|
||||
|
||||
var total = 0
|
||||
database.rawQuery(
|
||||
"SELECT * FROM messages WHERE $whereClause ORDER BY send_timestamp ${if (orderAscending) "ASC" else "DESC"}",
|
||||
whereArgs
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
cursorToLoggedMessage(cursor)?.let { loggedMessage ->
|
||||
block(loggedMessage)
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
|
||||
val edits = mutableListOf<LoggedChatEdit>()
|
||||
database.rawQuery(
|
||||
@@ -483,16 +888,7 @@ class LoggerWrapper(
|
||||
arrayOf(conversationId, fromTimestamp.toString())
|
||||
).use {
|
||||
while (it.moveToNext() && messages.size < limit) {
|
||||
val message = LoggedMessage(
|
||||
messageId = it.getLongOrNull("message_id") ?: continue,
|
||||
conversationId = it.getStringOrNull("conversation_id") ?: continue,
|
||||
userId = it.getStringOrNull("user_id") ?: continue,
|
||||
username = it.getStringOrNull("username") ?: continue,
|
||||
sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue,
|
||||
addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue,
|
||||
groupTitle = it.getStringOrNull("group_title"),
|
||||
messageData = it.getBlobOrNull("message_data") ?: continue
|
||||
)
|
||||
val message = cursorToLoggedMessage(it) ?: continue
|
||||
if (filter != null && !filter(message)) continue
|
||||
messages.add(message)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package me.eternal.purrfectsnap.common.config
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.JsonNull
|
||||
import com.google.gson.JsonObject
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import kotlin.reflect.KProperty
|
||||
@@ -80,9 +79,7 @@ open class ConfigContainer(
|
||||
properties.forEach { (propertyKey, propertyValue) ->
|
||||
if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach
|
||||
if (!includeSavedLocations && propertyKey.dataType.type == DataProcessors.Type.MAP_COORDINATES) return@forEach
|
||||
val serializedValue = propertyValue.getRaw()?.let {
|
||||
propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations)
|
||||
} ?: JsonNull.INSTANCE
|
||||
val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) }
|
||||
json.add(propertyKey.name, serializedValue)
|
||||
}
|
||||
return json
|
||||
|
||||
@@ -81,6 +81,7 @@ class ConfigParams(
|
||||
var inputCheck: ((String) -> Boolean)? = { true },
|
||||
var filenameFilter: ((String) -> Boolean)? = null,
|
||||
var versionCheck: VersionCheck? = null,
|
||||
var digitsOnlyInput: Boolean = false,
|
||||
) {
|
||||
val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
|
||||
|
||||
@@ -55,9 +55,7 @@ class Global : ConfigContainer() {
|
||||
}
|
||||
}
|
||||
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
|
||||
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
|
||||
profile.set("max")
|
||||
}
|
||||
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }
|
||||
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
|
||||
val disableMetrics = boolean("disable_metrics") { requireRestart() }
|
||||
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
|
||||
@@ -80,8 +78,6 @@ class Global : ConfigContainer() {
|
||||
|
||||
inner class UpdateSettings : ConfigContainer() {
|
||||
val autoUpdateCheck = boolean("auto_update_check", true)
|
||||
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
|
||||
val updateChannel = unique("update_channel", "stable", "prerelease")
|
||||
}
|
||||
|
||||
inner class UISettings : ConfigContainer() {
|
||||
|
||||
@@ -186,6 +186,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
val currentProfileSnapshot = string("current_profile_snapshot") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
val profileData = string("profile_data") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
inner class SpoofDeviceIdConfig : ConfigContainer() {
|
||||
|
||||
@@ -33,6 +33,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() }
|
||||
val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() }
|
||||
val sortSocialTabByStreakLength = boolean("sort_social_tab_by_streak_length").apply { set(true) }
|
||||
val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() }
|
||||
val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() }
|
||||
val hideQuickAddSuggestions = boolean("hide_quick_add_suggestions") { requireRestart() }
|
||||
@@ -56,7 +57,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
|
||||
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
|
||||
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
|
||||
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() }
|
||||
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator", "skip_own_indicators", "disable_indicators_in_groups") { requireRestart() }
|
||||
val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() }
|
||||
val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") {
|
||||
requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR)
|
||||
@@ -76,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
}
|
||||
|
||||
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
|
||||
|
||||
inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) {
|
||||
val customFollowersCount = string("custom_followers_count") {
|
||||
requireRestart()
|
||||
digitsOnlyInput = true
|
||||
inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } }
|
||||
}
|
||||
}
|
||||
|
||||
val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() }
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ enum class MessagingRuleType(
|
||||
val configNotices: Array<FeatureNotice> = emptyArray()
|
||||
) {
|
||||
STEALTH("stealth", true, Icons.Outlined.TrackChanges),
|
||||
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = false),
|
||||
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = false),
|
||||
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = true),
|
||||
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = true),
|
||||
HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"),
|
||||
AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline),
|
||||
AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"),
|
||||
@@ -65,6 +65,7 @@ enum class MessagingRuleType(
|
||||
AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist");
|
||||
|
||||
fun translateOptionKey(optionKey: String): String {
|
||||
if (key.contains("stealth")) return "features.options.friend_feed_menu_buttons.$key"
|
||||
return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
@@ -41,16 +42,111 @@ class BridgeClient(
|
||||
private var continuation: Continuation<Boolean>? = null
|
||||
private val connectSemaphore = Semaphore(permits = 1)
|
||||
private val reconnectSemaphore = Semaphore(permits = 1)
|
||||
private lateinit var service: BridgeInterface
|
||||
private val serviceStateLock = Any()
|
||||
@Volatile
|
||||
private var service: BridgeInterface? = null
|
||||
@Volatile
|
||||
private var serviceBinder: IBinder? = null
|
||||
@Volatile
|
||||
private var isBound = false
|
||||
@Volatile
|
||||
private var isHandlingServiceConnection = false
|
||||
private val connectionExecutor = Executors.newSingleThreadExecutor()
|
||||
private val legacyBindThread = HandlerThread("BridgeClient").apply { start() }
|
||||
private val legacyBindHandler by lazy { Handler(legacyBindThread.looper) }
|
||||
|
||||
private val onConnectedCallbacks = mutableListOf<suspend () -> Unit>()
|
||||
private var cachePurrfectSnapApkPath: String? = null
|
||||
|
||||
private val serviceDeathRecipient = IBinder.DeathRecipient {
|
||||
clearConnectedService()
|
||||
}
|
||||
|
||||
private fun clearConnectedServiceLocked() {
|
||||
serviceBinder?.let { binder ->
|
||||
runCatching { binder.unlinkToDeath(serviceDeathRecipient, 0) }
|
||||
}
|
||||
serviceBinder = null
|
||||
service = null
|
||||
}
|
||||
|
||||
private fun clearConnectedService() {
|
||||
synchronized(serviceStateLock) {
|
||||
clearConnectedServiceLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachConnectedService(binder: IBinder): Boolean {
|
||||
synchronized(serviceStateLock) {
|
||||
clearConnectedServiceLocked()
|
||||
serviceBinder = binder
|
||||
service = BridgeInterface.Stub.asInterface(binder)
|
||||
return runCatching {
|
||||
binder.linkToDeath(serviceDeathRecipient, 0)
|
||||
true
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("BridgeClient", "Failed to link bridge death recipient", throwable)
|
||||
clearConnectedServiceLocked()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServiceAlive(): Boolean {
|
||||
val binder = serviceBinder ?: service?.asBinder() ?: return false
|
||||
return binder.isBinderAlive && binder.pingBinder()
|
||||
}
|
||||
|
||||
private val connectedService: BridgeInterface
|
||||
get() {
|
||||
val currentService = service ?: throw DeadObjectException()
|
||||
val binder = currentService.asBinder()
|
||||
if (!binder.isBinderAlive || !binder.pingBinder()) throw DeadObjectException()
|
||||
return currentService
|
||||
}
|
||||
|
||||
private fun Context.unbindBridgeIfNeeded() {
|
||||
if (!isBound) return
|
||||
runCatching { unbindService(this@BridgeClient) }.onFailure { throwable ->
|
||||
if (throwable !is IllegalArgumentException) throw throwable
|
||||
}
|
||||
isBound = false
|
||||
}
|
||||
|
||||
private fun Context.bindBridge(intent: Intent): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bindService(
|
||||
intent,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
connectionExecutor,
|
||||
this@BridgeClient
|
||||
)
|
||||
} else {
|
||||
this::class.java.methods.firstOrNull {
|
||||
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
|
||||
}?.invoke(
|
||||
this,
|
||||
intent,
|
||||
this@BridgeClient,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
legacyBindHandler,
|
||||
Process.myUserHandle()
|
||||
) as? Boolean ?: false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRecoverableBinderFailure(throwable: Throwable): Boolean {
|
||||
return throwable is DeadObjectException ||
|
||||
throwable is RemoteException ||
|
||||
throwable.cause is DeadObjectException ||
|
||||
throwable.cause is RemoteException
|
||||
}
|
||||
|
||||
fun addOnConnectedCallback(initNow: Boolean = false, callback: suspend () -> Unit) {
|
||||
synchronized(onConnectedCallbacks) {
|
||||
onConnectedCallbacks.add(callback)
|
||||
}
|
||||
initNow.takeIf { it && this::service.isInitialized }?.let {
|
||||
initNow.takeIf { it && isServiceAlive() }?.let {
|
||||
runBlocking {
|
||||
callback()
|
||||
}
|
||||
@@ -67,7 +163,7 @@ class BridgeClient(
|
||||
}
|
||||
|
||||
suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? {
|
||||
if (this::service.isInitialized && service.asBinder().pingBinder()) {
|
||||
if (isServiceAlive()) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -75,10 +171,9 @@ class BridgeClient(
|
||||
val retryDelay = 3000L
|
||||
|
||||
return withTimeoutOrNull(connectionTimeout) {
|
||||
var result: Boolean? = null
|
||||
|
||||
for (retry in 0.. (connectionTimeout / retryDelay).toInt()) {
|
||||
result = withTimeoutOrNull(retryDelay) {
|
||||
val attempts = (connectionTimeout / retryDelay).toInt() + 1
|
||||
repeat(attempts) { attempt ->
|
||||
val result = withTimeoutOrNull(retryDelay) {
|
||||
suspendCancellableCoroutine { cancellableContinuation ->
|
||||
continuation = cancellableContinuation
|
||||
with(context.androidContext) {
|
||||
@@ -93,31 +188,12 @@ class BridgeClient(
|
||||
runCatching {
|
||||
val intent = Intent()
|
||||
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
|
||||
runCatching {
|
||||
if (this@BridgeClient::service.isInitialized) {
|
||||
unbindService(this@BridgeClient)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
bindService(
|
||||
intent,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
Executors.newSingleThreadExecutor(),
|
||||
this@BridgeClient
|
||||
)
|
||||
} else {
|
||||
val handler = Handler(HandlerThread("BridgeClient").apply { start() }.looper)
|
||||
this::class.java.methods.firstOrNull {
|
||||
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
|
||||
}?.invoke(
|
||||
this,
|
||||
intent,
|
||||
this@BridgeClient,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
handler,
|
||||
Process.myUserHandle()
|
||||
) ?: throw NoSuchMethodException("bindServiceAsUser")
|
||||
unbindBridgeIfNeeded()
|
||||
clearConnectedService()
|
||||
if (!bindBridge(intent)) {
|
||||
throw IllegalStateException("bindService returned false")
|
||||
}
|
||||
isBound = true
|
||||
}.onFailure {
|
||||
onFailure(it)
|
||||
resumeContinuation(false)
|
||||
@@ -125,46 +201,82 @@ class BridgeClient(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result != null) break
|
||||
if (result == true) {
|
||||
return@withTimeoutOrNull true
|
||||
}
|
||||
if (attempt + 1 < attempts) {
|
||||
delay(250L)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
this.service = BridgeInterface.Stub.asInterface(service)
|
||||
runBlocking {
|
||||
onConnectedCallbacks.forEach {
|
||||
runCatching {
|
||||
it()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to run onConnectedCallback", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
cachePurrfectSnapApkPath = this.service.applicationApkPath.also {
|
||||
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
|
||||
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
|
||||
context.softRestartApp()
|
||||
isHandlingServiceConnection = true
|
||||
try {
|
||||
if (!attachConnectedService(service)) {
|
||||
resumeContinuation(false)
|
||||
return
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
onConnectedCallbacks.forEach {
|
||||
runCatching {
|
||||
it()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to run onConnectedCallback", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val remoteApkPath = runCatching {
|
||||
connectedService.applicationApkPath
|
||||
}.getOrElse { throwable ->
|
||||
if (isRecoverableBinderFailure(throwable)) {
|
||||
Log.w("BridgeClient", "Bridge died during onServiceConnected initialization", throwable)
|
||||
} else {
|
||||
Log.e("BridgeClient", "Bridge initialization failed", throwable)
|
||||
}
|
||||
clearConnectedService()
|
||||
resumeContinuation(false)
|
||||
return
|
||||
}
|
||||
cachePurrfectSnapApkPath = remoteApkPath.also {
|
||||
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
|
||||
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
|
||||
context.softRestartApp()
|
||||
return
|
||||
}
|
||||
}
|
||||
resumeContinuation(true)
|
||||
} finally {
|
||||
isHandlingServiceConnection = false
|
||||
}
|
||||
resumeContinuation(true)
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
resumeContinuation(false)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
continuation = null
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName) {
|
||||
clearConnectedService()
|
||||
isBound = false
|
||||
resumeContinuation(false)
|
||||
}
|
||||
|
||||
private fun tryReconnect() {
|
||||
runBlocking {
|
||||
reconnectSemaphore.withPermit {
|
||||
if (service.asBinder().pingBinder()) return@runBlocking
|
||||
if (isServiceAlive()) return@withPermit
|
||||
Log.d("BridgeClient", "service is dead, restarting")
|
||||
val canLoad = connect {
|
||||
Log.e("BridgeClient", "connection failed", it)
|
||||
@@ -181,13 +293,16 @@ class BridgeClient(
|
||||
return runCatching {
|
||||
block()
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is DeadObjectException) {
|
||||
tryReconnect()
|
||||
return@getOrElse runCatching {
|
||||
block()
|
||||
}.getOrElse {
|
||||
Log.e("BridgeClient", "service call failed", it)
|
||||
throw it
|
||||
if (isRecoverableBinderFailure(throwable)) {
|
||||
clearConnectedService()
|
||||
if (!isHandlingServiceConnection) {
|
||||
tryReconnect()
|
||||
return@getOrElse runCatching {
|
||||
block()
|
||||
}.getOrElse {
|
||||
Log.e("BridgeClient", "service call failed", it)
|
||||
throw it
|
||||
}
|
||||
}
|
||||
}
|
||||
throw throwable
|
||||
@@ -197,15 +312,15 @@ class BridgeClient(
|
||||
fun broadcastLog(tag: String, level: String, message: String) {
|
||||
message.chunked(1024 * 256).forEach {
|
||||
runCatching {
|
||||
service.broadcastLog(tag, level, it)
|
||||
connectedService.broadcastLog(tag, level, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath }
|
||||
fun getApplicationApkPath(): String = safeServiceCall { connectedService.applicationApkPath }
|
||||
|
||||
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall {
|
||||
service.enqueueDownload(intent, callback)
|
||||
connectedService.enqueueDownload(intent, callback)
|
||||
}
|
||||
|
||||
fun convertMedia(
|
||||
@@ -215,40 +330,42 @@ class BridgeClient(
|
||||
audioCodec: String?,
|
||||
videoCodec: String?
|
||||
): ParcelFileDescriptor? = safeServiceCall {
|
||||
service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
|
||||
connectedService.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
|
||||
}
|
||||
|
||||
fun sync(callback: SyncCallback) {
|
||||
if (!context.database.hasMain()) return
|
||||
safeServiceCall {
|
||||
service.sync(callback)
|
||||
connectedService.sync(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerSync(scope: SocialScope, id: String) = safeServiceCall {
|
||||
service.triggerSync(scope.key, id)
|
||||
connectedService.triggerSync(scope.key, id)
|
||||
}
|
||||
|
||||
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
|
||||
safeServiceCall {
|
||||
val serializedGroups = groups.mapNotNull { it.toSerialized() }
|
||||
val serializedFriends = friends.mapNotNull { it.toSerialized() }
|
||||
|
||||
// Binder transaction limit is 1MB. Use 128KB chunks to avoid TransactionTooLargeException.
|
||||
val maxChunkBytes = 128 * 1024
|
||||
|
||||
fun chunkSerialized(values: List<String>): List<List<String>> {
|
||||
fun calculateParts(values: List<String>): List<List<String>> {
|
||||
if (values.isEmpty()) return listOf(emptyList())
|
||||
val result = mutableListOf<List<String>>()
|
||||
val currentChunk = mutableListOf<String>()
|
||||
var currentChunk = mutableListOf<String>()
|
||||
var currentSize = 0
|
||||
|
||||
values.forEach { value ->
|
||||
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
|
||||
val valueSize = value.toByteArray(Charsets.UTF_8).size + 32
|
||||
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
|
||||
result += currentChunk.toList()
|
||||
currentChunk.clear()
|
||||
currentChunk = mutableListOf()
|
||||
currentSize = 0
|
||||
}
|
||||
currentChunk += value
|
||||
currentChunk.add(value)
|
||||
currentSize += valueSize
|
||||
}
|
||||
|
||||
@@ -258,73 +375,71 @@ class BridgeClient(
|
||||
return result
|
||||
}
|
||||
|
||||
val groupChunks = chunkSerialized(serializedGroups)
|
||||
val friendChunks = chunkSerialized(serializedFriends)
|
||||
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
|
||||
val groupParts = calculateParts(serializedGroups)
|
||||
val friendParts = calculateParts(serializedFriends)
|
||||
val totalParts = maxOf(groupParts.size, friendParts.size)
|
||||
|
||||
context.log.info(
|
||||
"Sending social snapshot in $chunkCount chunk(s): " +
|
||||
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
|
||||
)
|
||||
context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends")
|
||||
|
||||
repeat(chunkCount) { index ->
|
||||
service.passGroupsAndFriends(
|
||||
groupChunks.getOrElse(index) { emptyList() },
|
||||
friendChunks.getOrElse(index) { emptyList() }
|
||||
repeat(totalParts) { index ->
|
||||
connectedService.passGroupsAndFriends(
|
||||
groupParts.getOrElse(index) { emptyList() },
|
||||
friendParts.getOrElse(index) { emptyList() },
|
||||
index,
|
||||
totalParts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {
|
||||
service.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
|
||||
connectedService.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
|
||||
}
|
||||
|
||||
fun getRuleIds(ruleType: MessagingRuleType): List<String> = safeServiceCall {
|
||||
service.getRuleIds(ruleType.key)
|
||||
connectedService.getRuleIds(ruleType.key)
|
||||
}
|
||||
|
||||
fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = safeServiceCall {
|
||||
service.setRule(targetUuid, type.key, state)
|
||||
connectedService.setRule(targetUuid, type.key, state)
|
||||
}
|
||||
|
||||
fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) }
|
||||
fun getScopeNotes(id: String): String? = safeServiceCall { connectedService.getScopeNotes(id) }
|
||||
|
||||
fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) }
|
||||
fun setScopeNotes(id: String, content: String?) = safeServiceCall { connectedService.setScopeNotes(id, content) }
|
||||
|
||||
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { service.getAllScopeNotes() }
|
||||
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { connectedService.getAllScopeNotes() }
|
||||
|
||||
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { service.setAllScopeNotes(notes) }
|
||||
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { connectedService.setAllScopeNotes(notes) }
|
||||
|
||||
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { service.scriptingInterface }
|
||||
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { connectedService.scriptingInterface }
|
||||
|
||||
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }
|
||||
fun getE2eeInterface(): E2eeInterface = safeServiceCall { connectedService.e2eeInterface }
|
||||
|
||||
fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger }
|
||||
fun getMessageLogger(): LoggerInterface = safeServiceCall { connectedService.logger }
|
||||
|
||||
fun getTracker(): TrackerInterface = safeServiceCall { service.tracker }
|
||||
fun getTracker(): TrackerInterface = safeServiceCall { connectedService.tracker }
|
||||
|
||||
fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage }
|
||||
fun getAccountStorage(): AccountStorage = safeServiceCall { connectedService.accountStorage }
|
||||
|
||||
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager }
|
||||
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { connectedService.fileHandleManager }
|
||||
|
||||
fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager }
|
||||
fun getLocationManager(): LocationManager = safeServiceCall { connectedService.locationManager }
|
||||
|
||||
fun getTaskInterface(): TaskInterface = safeServiceCall { service.taskInterface }
|
||||
fun getTaskInterface(): TaskInterface = safeServiceCall { connectedService.taskInterface }
|
||||
|
||||
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }
|
||||
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { connectedService.registerMessagingBridge(bridge) }
|
||||
|
||||
fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) }
|
||||
fun closeOverlay() = safeServiceCall { service.closeOverlay() }
|
||||
fun openOverlay(type: OverlayType) = safeServiceCall { connectedService.openOverlay(type.key) }
|
||||
fun closeOverlay() = safeServiceCall { connectedService.closeOverlay() }
|
||||
|
||||
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }
|
||||
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { connectedService.registerConfigStateListener(listener) }
|
||||
|
||||
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
|
||||
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { connectedService.getDebugProp(name, defaultValue) }
|
||||
|
||||
fun startCallDownload(
|
||||
startTimestamp: Long,
|
||||
author: String,
|
||||
): CallDownloadSession {
|
||||
return safeServiceCall { service.startCallDownload(startTimestamp, author) }
|
||||
return safeServiceCall { connectedService.startCallDownload(startTimestamp, author) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class EventDispatcher(
|
||||
cacheHook(
|
||||
methodParam.thisObject<Any>()::class.java
|
||||
) {
|
||||
hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param ->
|
||||
hook(bindMethod.get().toString(), HookStage.AFTER) bindViewMethod@{ param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val view = instance::class.java.methods.firstOrNull {
|
||||
it.name == getViewMethod.get().toString()
|
||||
@@ -161,7 +161,6 @@ class EventDispatcher(
|
||||
adapter = param
|
||||
}
|
||||
) {
|
||||
if (canceled) param.setResult(null)
|
||||
postHookEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
} && getRuleState() != null
|
||||
}
|
||||
|
||||
fun canUseRule(conversationId: String): Boolean {
|
||||
open fun canUseRule(conversationId: String): Boolean {
|
||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -69,6 +69,16 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
|
||||
{ true }, isAppExperiment = true)
|
||||
|
||||
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
|
||||
{ true })
|
||||
|
||||
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
|
||||
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
|
||||
|
||||
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
|
||||
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
|
||||
}
|
||||
|
||||
run {
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
val level7Value = { _: ConfigKeyInfo -> 700 }
|
||||
@@ -172,15 +182,6 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
},
|
||||
{ false })
|
||||
|
||||
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
|
||||
{ true })
|
||||
|
||||
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
|
||||
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
|
||||
|
||||
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
|
||||
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
|
||||
}
|
||||
overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false })
|
||||
|
||||
classReference.getAsClass()?.hook(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -17,11 +16,11 @@ import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.config.ModConfig
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
import me.eternal.purrfectsnap.common.data.MessageUpdate
|
||||
@@ -33,6 +32,7 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import java.util.*
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -42,7 +42,7 @@ import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* AutoOpenSnaps: High-performance engine with real-time diagnostics.
|
||||
* Optimized for 20+ snaps/s with accurate stats and background resilience.
|
||||
* Optimized for background resilience and industrial stability.
|
||||
*/
|
||||
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
|
||||
companion object {
|
||||
@@ -54,12 +54,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN"
|
||||
private const val PREF_TOTAL_OPENED = "auto_open_total_opened"
|
||||
private const val PREF_SESSION_START = "auto_open_session_start"
|
||||
|
||||
private const val LAZY_SAVE_INTERVAL_MS = 600_000L
|
||||
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val isPaused = AtomicBoolean(false)
|
||||
private val isScreenOn = AtomicBoolean(true)
|
||||
private val engineActive = AtomicBoolean(true)
|
||||
private val totalProcessed = AtomicInteger(0)
|
||||
private val sessionProcessed = AtomicInteger(0)
|
||||
@@ -67,9 +67,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
private val averageProcessingTime = AtomicLong(800)
|
||||
private val lastSnapProcessedAt = AtomicLong(0)
|
||||
|
||||
private val snapChannel = Channel<SnapQueueItem>(Channel.UNLIMITED)
|
||||
private val snapQueue = MutableSharedFlow<Long>(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
private val openedSnapsIds = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val queuedSnaps = LinkedList<SnapQueueItem>()
|
||||
private val deadLetterQueue = mutableListOf<SnapQueueItem>()
|
||||
private var engineJob: Job? = null
|
||||
private val engineDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
|
||||
@@ -78,19 +79,22 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
|
||||
private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) }
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wakeLockCooldownJob: Job? = null
|
||||
|
||||
private var currentStatusText = "Monitoring..."
|
||||
private var currentSpeedText = "Full Speed"
|
||||
private var lastNotificationUpdate = 0L
|
||||
private var lastNotificationStateHash = 0
|
||||
private val notificationUpdateDelay = 1000L
|
||||
private val pendingNotificationUpdate = AtomicBoolean(false)
|
||||
private val snapTimestamps = LinkedList<Long>()
|
||||
private var lastConversationId: String? = null
|
||||
private var lastQueueActivity = System.currentTimeMillis()
|
||||
|
||||
private val isSaving = AtomicBoolean(false)
|
||||
private val needsSaving = AtomicBoolean(false)
|
||||
private val lastSaveTime = AtomicLong(System.currentTimeMillis())
|
||||
private var isThermalThrottled = false
|
||||
private var lastThermalThrottleAt = 0L
|
||||
private var actionReceiver: BroadcastReceiver? = null
|
||||
|
||||
private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg")
|
||||
private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg")
|
||||
@@ -99,7 +103,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val now = System.currentTimeMillis(); val window = 5000L
|
||||
synchronized(snapTimestamps) {
|
||||
snapTimestamps.removeIf { now - it > window }
|
||||
// Smoother calculation for high-frequency bursts
|
||||
return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0))
|
||||
}
|
||||
}
|
||||
@@ -110,10 +113,11 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (autoOpenConfig.globalState != true) return
|
||||
|
||||
restorePersistence()
|
||||
createNotificationChannels()
|
||||
|
||||
// NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background"
|
||||
if ((autoOpenConfig.allowRunningInBackground as PropertyValue<Boolean>).get()) {
|
||||
runCatching {
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
@@ -121,6 +125,13 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val state = param.arg<Any>(0).toString()
|
||||
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
|
||||
}
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
methods.firstOrNull { it.name == "appStateChanged" }?.let { method ->
|
||||
val enumClass = method.parameterTypes[0]
|
||||
val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" }
|
||||
if (activeState != null) method.invoke(param.thisObject<Any>(), activeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply {
|
||||
hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) }
|
||||
@@ -129,6 +140,27 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
}
|
||||
|
||||
// Background Watchdog: Periodically verifies engine health
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) {
|
||||
while (isActive && engineActive.get()) {
|
||||
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
if (remainingCount > 0) {
|
||||
lastQueueActivity = System.currentTimeMillis(); acquireWakeLock()
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
} else {
|
||||
if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null }
|
||||
if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) }
|
||||
}
|
||||
if (System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
startWakeLockCooldown()
|
||||
}
|
||||
}
|
||||
updateStatusNotification()
|
||||
delay(30000) // 30s watchdog cycle
|
||||
}
|
||||
}
|
||||
|
||||
setupReceivers()
|
||||
startEngineWorker()
|
||||
setupDetector()
|
||||
@@ -136,90 +168,106 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
|
||||
private fun startEngineWorker() {
|
||||
engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) {
|
||||
while (engineActive.get()) {
|
||||
val item = try { snapChannel.receive() } catch (e: Exception) { break }
|
||||
|
||||
while (isPaused.get() && engineActive.get()) {
|
||||
currentStatusText = "Paused"; updateStatusNotification(); delay(500)
|
||||
}
|
||||
if (!engineActive.get()) break
|
||||
snapQueue.collect {
|
||||
while (engineActive.get()) {
|
||||
val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break
|
||||
|
||||
updateStatusNotification()
|
||||
if (!validateEnvironmentalConstraints()) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
continue
|
||||
}
|
||||
while (isPaused.get() && engineActive.get()) {
|
||||
currentStatusText = "Paused"; updateStatusNotification(); delay(500)
|
||||
}
|
||||
if (!engineActive.get()) break
|
||||
|
||||
// SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off
|
||||
val isSafe = (autoOpenConfig.safeProcessing as PropertyValue<Boolean>).get()
|
||||
if (lastConversationId != null && lastConversationId != item.conversationId) {
|
||||
delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue<Int>).get().toLong() else 40L)
|
||||
}
|
||||
lastConversationId = item.conversationId
|
||||
|
||||
processSnapItem(item)
|
||||
lastSnapProcessedAt.set(System.currentTimeMillis())
|
||||
|
||||
// HIGH SPEED: 10ms floor for 20+ snaps/s
|
||||
val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue<Int>).get().toLong()
|
||||
if (isSafe) {
|
||||
delay(Random.nextLong(baseDelay, baseDelay + 200))
|
||||
} else {
|
||||
delay(baseDelay.coerceAtMost(10))
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = "Monitoring..."
|
||||
updateStatusNotification()
|
||||
if (!validateEnvironmentalConstraints()) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.add(0, item) }
|
||||
continue
|
||||
}
|
||||
|
||||
val isSafe = (autoOpenConfig.safeProcessing as PropertyValue<Boolean>).get()
|
||||
if (lastConversationId != null && lastConversationId != item.conversationId) {
|
||||
delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue<Int>).get().toLong() else 40L)
|
||||
}
|
||||
lastConversationId = item.conversationId
|
||||
|
||||
processSnapItem(item)
|
||||
lastSnapProcessedAt.set(System.currentTimeMillis())
|
||||
|
||||
// Process at natural network speed when safety is disabled
|
||||
val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue<Int>).get().toLong()
|
||||
if (isSafe) {
|
||||
delay(Random.nextLong(baseDelay, baseDelay + 200))
|
||||
} else {
|
||||
if (baseDelay > 0) delay(baseDelay)
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = "Monitoring..."
|
||||
updateStatusNotification()
|
||||
saveQueueToDisk() // Batch complete save
|
||||
startWakeLockCooldown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processSnapItem(item: SnapQueueItem) {
|
||||
// Verify database state on background thread before processing
|
||||
val dbMessage = withContext(Dispatchers.IO) { this@AutoOpenSnaps.context.database.getConversationMessageFromId(item.messageId) }
|
||||
if (dbMessage?.isViewedByUser == 1) {
|
||||
return
|
||||
}
|
||||
|
||||
currentStatusText = "Active"; updateStatusNotification()
|
||||
var success = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue<Int>).get()) {
|
||||
if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break
|
||||
|
||||
if (messaging.conversationManager == null) {
|
||||
|
||||
if (messaging.conversationManager == null) {
|
||||
runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() }
|
||||
delay(1000)
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
success = withContext(Dispatchers.IO) { performOpen(item) }
|
||||
|
||||
success = performOpen(item)
|
||||
if (success) {
|
||||
// IMPORTANT: Item only removed after successful processing to ensure Stats sync
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp()
|
||||
|
||||
// Industrial Interval Check: Only write to disk once every 10 minutes during floods
|
||||
if (System.currentTimeMillis() - lastSaveTime.get() > 600000) {
|
||||
saveQueueToDisk()
|
||||
lastSaveTime.set(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
|
||||
triggerLazySave(); break
|
||||
break
|
||||
}
|
||||
delay((autoOpenConfig.retryDelay as PropertyValue<Int>).get().toLong())
|
||||
}
|
||||
if (!success && !isPaused.get() && engineActive.get()) {
|
||||
logError("Engine failed to open Snap: ${item.messageId}")
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
synchronized(openedSnapsIds) { openedSnapsIds.remove(item.messageId) }
|
||||
synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } }
|
||||
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
|
||||
openedSnapsIds.remove(item.messageId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpen(item: SnapQueueItem): Boolean {
|
||||
val manager = messaging.conversationManager ?: return false
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
runCatching {
|
||||
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
|
||||
if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) }
|
||||
else if (item.serverMessageId != 0L) {
|
||||
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
|
||||
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
|
||||
}
|
||||
} else { cont.resume(false) }
|
||||
}
|
||||
}.onFailure { logError("Bridge Error", it); cont.resume(false) }
|
||||
return withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
runCatching {
|
||||
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
|
||||
if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) }
|
||||
else if (item.serverMessageId != 0L) {
|
||||
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
|
||||
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
|
||||
}
|
||||
} else { cont.resume(false) }
|
||||
}
|
||||
}.onFailure { logError("Bridge Error", it); cont.resume(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,17 +278,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val isIdle = isDeviceIdle()
|
||||
val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue<Boolean>).get()
|
||||
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
|
||||
|
||||
|
||||
val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue<Boolean>).get() && !isWifi
|
||||
val idleStop = onlyIdle && !isIdle && !inSleepWindow
|
||||
|
||||
|
||||
when {
|
||||
wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) }
|
||||
idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) }
|
||||
else -> {
|
||||
else -> {
|
||||
val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue<Boolean>).get() && isThermalThrottled
|
||||
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
|
||||
return true
|
||||
return true
|
||||
}
|
||||
}
|
||||
updateStatusNotification()
|
||||
@@ -252,52 +300,76 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
|
||||
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
|
||||
val message = event.message
|
||||
|
||||
// 1. Basic Filters & Self-Check
|
||||
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe
|
||||
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
|
||||
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
|
||||
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
val serverMessageId = message.orderKey ?: 0L
|
||||
|
||||
val contentType = message.messageContent?.contentType
|
||||
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
|
||||
if (!canUseRule(conversationId)) return@subscribe
|
||||
|
||||
// 2. Memory Gating: Prevent processing the same session snap multiple times
|
||||
if (openedSnapsIds.contains(clientMessageId)) return@subscribe
|
||||
|
||||
// 3. Database Authority: Immediate check to see if snap is already opened
|
||||
val dbMessage = this@AutoOpenSnaps.context.database.getConversationMessageFromId(clientMessageId)
|
||||
if (dbMessage?.isViewedByUser == 1) return@subscribe
|
||||
|
||||
// 4. Temporal Gating: Ignore ancient unread snaps (fixes 'Ghost Storm' during sync)
|
||||
val now = System.currentTimeMillis()
|
||||
val messageTime = message.messageMetadata?.createdAt ?: 0L
|
||||
if (now - messageTime > 28_800_000L) { // 8-hour window
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
openedSnapsIds.add(clientMessageId)
|
||||
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
|
||||
synchronized(queuedSnaps) { queuedSnaps.add(item) }
|
||||
snapChannel.trySend(item)
|
||||
|
||||
acquireWakeLock(); updateStatusNotification(); triggerLazySave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerLazySave() {
|
||||
needsSaving.set(true)
|
||||
if (isSaving.compareAndSet(false, true)) {
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) {
|
||||
while (needsSaving.get() && engineActive.get()) {
|
||||
needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS)
|
||||
}
|
||||
isSaving.set(false)
|
||||
}
|
||||
synchronized(queuedSnaps) { queuedSnaps.add(item) }
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
|
||||
acquireWakeLock(); updateStatusNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQueueToDisk() {
|
||||
prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) }
|
||||
prefs.edit {
|
||||
putInt(PREF_TOTAL_OPENED, totalProcessed.get())
|
||||
putLong(PREF_SESSION_START, sessionStartTime.get())
|
||||
synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePersistence() {
|
||||
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0)
|
||||
if (System.currentTimeMillis() - savedStartTime > 21600000) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - savedStartTime > 21600000) return
|
||||
totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime)
|
||||
val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null)
|
||||
if (!savedQueueJson.isNullOrBlank()) {
|
||||
runCatching {
|
||||
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, object : TypeToken<List<SnapQueueItem>>() {}.type)
|
||||
synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isWifiConnected(): Boolean {
|
||||
val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
|
||||
// Check for any available network with a WiFi or Ethernet transport
|
||||
return cm.allNetworks.any { network ->
|
||||
cm.getNetworkCapabilities(network)?.let { caps ->
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
} == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode
|
||||
@@ -307,12 +379,21 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
wakeLockCooldownJob?.cancel()
|
||||
if (wakeLock?.isHeld == true) return
|
||||
wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) }
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null }
|
||||
|
||||
private fun startWakeLockCooldown() {
|
||||
wakeLockCooldownJob?.cancel()
|
||||
wakeLockCooldownJob = this@AutoOpenSnaps.context.coroutineScope.launch {
|
||||
delay(30000)
|
||||
releaseWakeLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) })
|
||||
@@ -321,9 +402,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
|
||||
private fun updateStatusNotification(force: Boolean = false) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!isScreenOn.get() && !force) return
|
||||
if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) {
|
||||
if (pendingNotificationUpdate.compareAndSet(false, true)) {
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -335,31 +417,35 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val processed = sessionProcessed.get()
|
||||
val total = totalProcessed.get()
|
||||
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
|
||||
// Industrial State Hashing: Prevent redundant redraws and CPU wakeups
|
||||
val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get())
|
||||
if (currentStateHash == lastNotificationStateHash && remaining == 0) return
|
||||
lastNotificationStateHash = currentStateHash
|
||||
|
||||
val isWorking = remaining > 0
|
||||
val speed = if (isWorking) getSnapsPerSecond() else 0.0
|
||||
|
||||
|
||||
lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false)
|
||||
|
||||
|
||||
val sessionTotal = processed + remaining
|
||||
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
|
||||
val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
|
||||
|
||||
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
|
||||
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY)
|
||||
|
||||
// ICON LOGIC: Pause, Monitoring (Sync), or Active (Play)
|
||||
val iconRes = when {
|
||||
isPaused.get() -> android.R.drawable.ic_media_pause
|
||||
!isWorking -> android.R.drawable.ic_popup_sync
|
||||
else -> android.R.drawable.ic_media_play
|
||||
}
|
||||
builder.setSmallIcon(iconRes)
|
||||
|
||||
builder.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else if (!isWorking) android.R.drawable.ic_popup_sync else android.R.drawable.ic_media_play)
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
|
||||
|
||||
val isCompact = (autoOpenConfig.compactNotification as PropertyValue<Boolean>).get()
|
||||
if (isWorking) {
|
||||
builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)")
|
||||
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
|
||||
if (isCompact) {
|
||||
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
|
||||
} else {
|
||||
builder.setSubText("")
|
||||
}
|
||||
builder.setProgress(sessionTotal, processed, false)
|
||||
} else {
|
||||
builder.setContentText("$processed Opened Today │ $total Total")
|
||||
@@ -383,10 +469,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
val speedNotion = if (isWorking) currentSpeedText else "Idle"
|
||||
val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s"
|
||||
append("└─ Speed: $speedNotion ($speedValue)\n")
|
||||
append("└─ Speed: $speedNotion ($speedValue)")
|
||||
|
||||
if ((autoOpenConfig.showQueuePreview as PropertyValue<Boolean>).get()) {
|
||||
append("\nQUEUE PREVIEW\n")
|
||||
append("\n\nQUEUE PREVIEW\n")
|
||||
if (isWorking && remaining > 0) {
|
||||
recentSnaps.reversed().forEach { item ->
|
||||
append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n")
|
||||
@@ -400,21 +486,22 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
builder.setStyle(bigTextStyle)
|
||||
}
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||
}
|
||||
|
||||
private fun createPendingIntent(action: String): PendingIntent {
|
||||
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
|
||||
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
private fun setupReceivers() {
|
||||
val actionReceiver = object : BroadcastReceiver() {
|
||||
actionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) }
|
||||
ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) }
|
||||
ACTION_STOP_ENGINE -> shutdownFeature()
|
||||
Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
|
||||
Intent.ACTION_SCREEN_OFF -> { isScreenOn.set(false) }
|
||||
Intent.ACTION_BATTERY_CHANGED -> {
|
||||
val temp = intent.getIntExtra("temperature", 0) / 10f
|
||||
if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() }
|
||||
@@ -423,21 +510,57 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(ACTION_STOP_ENGINE); addAction(Intent.ACTION_BATTERY_CHANGED) }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PAUSE_RESUME)
|
||||
addAction(ACTION_CLEAR_QUEUE)
|
||||
addAction(ACTION_STOP_ENGINE)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_BATTERY_CHANGED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver!!, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver!!, filter)
|
||||
}
|
||||
|
||||
private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } }
|
||||
|
||||
private fun shutdownFeature() {
|
||||
|
||||
private fun shutdownFeature() {
|
||||
engineActive.set(false)
|
||||
snapChannel.close()
|
||||
engineJob?.cancel()
|
||||
releaseWakeLock()
|
||||
cancelStatusNotification()
|
||||
saveQueueToDisk()
|
||||
|
||||
// Permanently disable the feature in settings
|
||||
autoOpenConfig.globalState = false
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch {
|
||||
runCatching {
|
||||
val field = context::class.java.getDeclaredField("_config").apply { isAccessible = true }
|
||||
val modConfig = (field.get(context) as Lazy<*>).value as ModConfig
|
||||
modConfig.writeConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// Surgical clean-up: release resources and listeners
|
||||
actionReceiver?.let {
|
||||
runCatching { this@AutoOpenSnaps.context.androidContext.unregisterReceiver(it) }
|
||||
}
|
||||
actionReceiver = null
|
||||
wakeLockCooldownJob?.cancel()
|
||||
|
||||
// Grace period for WakeLock release
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch {
|
||||
delay(60000)
|
||||
releaseWakeLock()
|
||||
}
|
||||
|
||||
// Show final "Stopped" notice
|
||||
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
|
||||
.setOngoing(false)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_close_clear_cancel)
|
||||
.setContentTitle("Auto-Open")
|
||||
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
|
||||
|
||||
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
|
||||
}
|
||||
|
||||
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
|
||||
fun getInterface(): AutoOpenInterface {
|
||||
@@ -450,7 +573,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
|
||||
private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown"
|
||||
private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat"
|
||||
private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" }
|
||||
private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" }
|
||||
}
|
||||
|
||||
data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis())
|
||||
|
||||
@@ -144,8 +144,24 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
}
|
||||
|
||||
private fun getRandomizedProfile(): RandomizedDeviceProfile {
|
||||
val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable()
|
||||
return randomizedProfile ?: RandomizedDeviceProfileStore
|
||||
if (randomizedProfile != null) return randomizedProfile!!
|
||||
|
||||
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
|
||||
val configProfileJson = spoofConfig.profileData.getNullable()
|
||||
|
||||
if (!configProfileJson.isNullOrBlank()) {
|
||||
runCatching {
|
||||
val profile = RandomizedDeviceProfile.fromJson(configProfileJson)
|
||||
randomizedProfile = profile
|
||||
context.log.verbose("Using restored randomized device profile from config")
|
||||
return profile
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to parse restored device profile from config, generating fresh one: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val generationToken = spoofConfig.profileGenerationToken.getNullable()
|
||||
return RandomizedDeviceProfileStore
|
||||
.getOrCreate(context.androidContext, context.log, generationToken)
|
||||
.also { profile ->
|
||||
randomizedProfile = profile
|
||||
@@ -155,18 +171,23 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
|
||||
private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) {
|
||||
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
|
||||
val profileJson = profile.toJson().toString()
|
||||
val snapshot = profile.toJson().toString(2)
|
||||
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return
|
||||
|
||||
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot && spoofConfig.profileData.getNullable() == profileJson) return
|
||||
|
||||
spoofConfig.currentProfileSnapshot.set(snapshot)
|
||||
spoofConfig.profileData.set(profileJson) // Synchronize raw profile data for multi-process persistence
|
||||
|
||||
runCatching {
|
||||
val field = context.javaClass.getDeclaredField("_config\$delegate")
|
||||
field.isAccessible = true
|
||||
val lazyConfig = field.get(context) as Lazy<*>
|
||||
val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching
|
||||
modConfig.writeConfig(dispatchConfigListener = false)
|
||||
context.log.verbose("Persisted randomized device profile snapshot to config")
|
||||
context.log.verbose("Persisted randomized device profile data to config")
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}")
|
||||
context.log.warn("Failed to persist randomized device profile: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,33 +70,37 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||
private val queuedSplitItems = ArrayDeque<Any>()
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var queuedOverrideSnapDurationMs: Int? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
|
||||
fun setQueuedOverrideType(value: String?) {
|
||||
fun setQueuedOverrideType(value: String?, snapDurationMs: Int? = 10_000) {
|
||||
queuedOverrideType = value
|
||||
queuedOverrideSnapDurationMs = if (value == null) null else snapDurationMs
|
||||
}
|
||||
fun getQueuedOverrideType(): String? = queuedOverrideType
|
||||
fun getQueuedOverrideSnapDurationMs(): Int? = queuedOverrideSnapDurationMs
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
queuedSplitCleanupItems.values.toList().forEach { item ->
|
||||
cleanup?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
queuedSplitCleanupItems.clear()
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
}
|
||||
fun sendReusableOriginalItem(): Boolean {
|
||||
val item = reusableOriginalItem ?: return false
|
||||
@@ -110,30 +114,34 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
queuedSplitCleanupItems[it.itemId] = it
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
val item = originalUnsplitItem ?: return false
|
||||
val overrideType = queuedOverrideType
|
||||
val overrideSnapDurationMs = queuedOverrideSnapDurationMs
|
||||
clearQueuedSplitItems(deleteTempItems = true)
|
||||
queuedOverrideType = overrideType
|
||||
queuedOverrideSnapDurationMs = overrideSnapDurationMs
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
queuedSplitCleanupItems.remove(itemId)?.let { item ->
|
||||
cleanupItemHandler?.invoke(item.uri, item.filePath)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
return false
|
||||
}
|
||||
val next = queuedSplitItems.removeFirstOrNull() ?: run {
|
||||
queuedOverrideType = null
|
||||
queuedOverrideSnapDurationMs = null
|
||||
return false
|
||||
}
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
@@ -151,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
val uri: String,
|
||||
val filePath: String? = null
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
@@ -301,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
return PreparedMediaItem(
|
||||
itemId = itemId,
|
||||
durationMs = durationMs,
|
||||
uri = uri.toString(),
|
||||
filePath = file.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
@@ -423,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
cleanupItemHandler = { uriString, filePath ->
|
||||
runCatching {
|
||||
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
|
||||
filePath?.let { path ->
|
||||
val file = File(path)
|
||||
if (file.exists()) file.delete()
|
||||
}
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
if (it.message?.contains("no access") == false) {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
|
||||
@@ -2,7 +2,6 @@ package me.eternal.purrfectsnap.core.features.impl.global
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
|
||||
@@ -35,7 +34,6 @@ class AdBlockFix : Feature("AdBlockFix") {
|
||||
|
||||
hookFeedEntryTracking()
|
||||
hookMessagingFeedCallbacks()
|
||||
hookChatFeedRowSuppression()
|
||||
hookOperaAutoSkip()
|
||||
}
|
||||
|
||||
@@ -45,7 +43,7 @@ class AdBlockFix : Feature("AdBlockFix") {
|
||||
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
|
||||
?: return@hookConstructor
|
||||
|
||||
if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) {
|
||||
if (isCampaignFeedEntry(feedEntry)) {
|
||||
adConversationIds.add(conversationId)
|
||||
}
|
||||
}
|
||||
@@ -123,36 +121,20 @@ class AdBlockFix : Feature("AdBlockFix") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookChatFeedRowSuppression() {
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
val modelDump = event.prevModel.toString()
|
||||
event.friendFeedItem { conversationId ->
|
||||
if (adConversationIds.contains(conversationId) || isChatAdShareModel(modelDump)) {
|
||||
hideBoundChatFeedRow(event.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideBoundChatFeedRow(view: View) {
|
||||
view.hideViewCompletely()
|
||||
(view.parent as? View)?.hideViewCompletely()
|
||||
(view.parent?.parent as? View)?.hideViewCompletely()
|
||||
}
|
||||
|
||||
private fun hookOperaAutoSkip() {
|
||||
onNextActivityCreate {
|
||||
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
|
||||
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
|
||||
val resolvedMethod = methodName.get() ?: return@forEach
|
||||
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val viewState = runCatching {
|
||||
param.thisObject<Any>().getObjectField(viewStateField.get()!!)?.toString()
|
||||
instance::class.java.methods.firstOrNull { it.name.contains("ViewState") || it.name == "g" }?.invoke(instance)?.toString()
|
||||
}.getOrNull() ?: return@hook
|
||||
if (viewState != "FULLY_DISPLAYED") return@hook
|
||||
|
||||
val layerList = runCatching {
|
||||
param.thisObject<Any>().getObjectField(layerListField.get()!!) as? ArrayList<*>
|
||||
instance::class.java.methods.firstOrNull { it.name.contains("LayerList") || it.name == "l" }?.invoke(instance) as? ArrayList<*>
|
||||
}.getOrNull() ?: return@hook
|
||||
val paramMap = runCatching {
|
||||
layerList.map { Layer(it).paramMap }.firstOrNull()
|
||||
@@ -209,25 +191,6 @@ class AdBlockFix : Feature("AdBlockFix") {
|
||||
?.getObjectFieldOrNull("mCampaignMetadata") != null
|
||||
}
|
||||
|
||||
private fun isChatAdShareFeedEntry(feedEntry: Any): Boolean {
|
||||
val interactionDump = feedEntry.getObjectFieldOrNull("mInteractionInfo")?.toString().orEmpty()
|
||||
val displayDump = feedEntry.getObjectFieldOrNull("mDisplayInfo")?.toString().orEmpty()
|
||||
val combined = "$interactionDump $displayDump"
|
||||
return isChatAdShareModel(combined)
|
||||
}
|
||||
|
||||
private fun isChatAdShareModel(modelDump: String): Boolean {
|
||||
if (modelDump.isBlank()) return false
|
||||
return modelDump.contains("CHAT_AD_SHARE") ||
|
||||
modelDump.contains("AD_SHARE") ||
|
||||
modelDump.contains("ChatAd") ||
|
||||
modelDump.contains("chat_ad_share") ||
|
||||
modelDump.contains("chat_sponsored_snap") ||
|
||||
modelDump.contains("CommonAttachmentViewModel") ||
|
||||
modelDump.contains("visibilityFeedbackURL") ||
|
||||
modelDump.contains("pageLoadPingURL")
|
||||
}
|
||||
|
||||
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
|
||||
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
|
||||
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {
|
||||
|
||||
@@ -17,67 +17,59 @@ class SnapchatPlus: Feature("SnapchatPlus") {
|
||||
|
||||
override fun init() {
|
||||
val snapchatPlusTier = context.config.global.snapchatPlus.getNullable()
|
||||
if (snapchatPlusTier == null || snapchatPlusTier == "not_subscribed") return
|
||||
|
||||
if (snapchatPlusTier != null) {
|
||||
context.mappings.useMapper(PlusSubscriptionMapper::class) {
|
||||
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
|
||||
param.thisObject<Any>().dataBuilder {
|
||||
//subscription tier
|
||||
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> "NO_ACCESS"
|
||||
"basic" -> "SNAPCHAT_PLUS"
|
||||
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
|
||||
else -> "SNAPCHAT_PLUS"
|
||||
})
|
||||
} else {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> 1
|
||||
"basic" -> 2
|
||||
"ad_free" -> 3
|
||||
else -> 2
|
||||
})
|
||||
}
|
||||
// Pre-calculate custom purchase date to eliminate main thread lag
|
||||
val customPurchaseDateRaw = context.config.global.snapchatPlusPurchaseDate.get().trim()
|
||||
val customPurchaseDateMillis = if (customPurchaseDateRaw.isNotEmpty()) {
|
||||
runCatching {
|
||||
LocalDate.parse(customPurchaseDateRaw, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
} else (System.currentTimeMillis() - 7776000000L) // 3 months fallback
|
||||
|
||||
//subscription status
|
||||
set(statusField.getAsString()!!, 2)
|
||||
|
||||
val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L
|
||||
val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim()
|
||||
val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) {
|
||||
runCatching {
|
||||
LocalDate
|
||||
.parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
set(
|
||||
originalSubscriptionTimeMillisField.getAsString()!!,
|
||||
customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime
|
||||
)
|
||||
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
|
||||
context.mappings.useMapper(PlusSubscriptionMapper::class) {
|
||||
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
|
||||
param.thisObject<Any>().dataBuilder {
|
||||
//subscription tier
|
||||
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> "NO_ACCESS"
|
||||
"basic" -> "SNAPCHAT_PLUS"
|
||||
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
|
||||
else -> "SNAPCHAT_PLUS"
|
||||
})
|
||||
} else {
|
||||
set(tierField.getAsString()!!, when (snapchatPlusTier) {
|
||||
"not_subscribed" -> 1
|
||||
"basic" -> 2
|
||||
"ad_free" -> 3
|
||||
else -> 2
|
||||
})
|
||||
}
|
||||
|
||||
//subscription status
|
||||
set(statusField.getAsString()!!, 2)
|
||||
|
||||
set(
|
||||
originalSubscriptionTimeMillisField.getAsString()!!,
|
||||
customPurchaseDateMillis
|
||||
)
|
||||
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force enable all premium features in the catalog
|
||||
if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) {
|
||||
findClass("com.snap.plus.FeatureCatalog").methods.last {
|
||||
!it.name.contains("init") &&
|
||||
it.parameterTypes.isNotEmpty() &&
|
||||
it.parameterTypes[0].name != "java.lang.Boolean"
|
||||
}.hook(HookStage.BEFORE) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val firstArg = param.argNullable<Any>(0) ?: return@hook
|
||||
|
||||
instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName ->
|
||||
instance.setObjectField(fieldName, firstArg)
|
||||
runCatching {
|
||||
val featureCatalogClass = findClass("com.snap.plus.FeatureCatalog")
|
||||
featureCatalogClass.hook("isFeatureEnabled", HookStage.BEFORE) { param ->
|
||||
param.setResult(true)
|
||||
}
|
||||
context.log.verbose("Successfully unlocked premium Snapchat features")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -28,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.ModContext
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
@@ -64,38 +72,126 @@ import kotlin.time.toDuration
|
||||
class SendOverride : Feature("Send Override") {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
|
||||
private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status"
|
||||
private const val STATUS_NOTIFICATION_ID = 54322
|
||||
private const val COMPLETION_NOTIFICATION_ID = 54323
|
||||
|
||||
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME"
|
||||
const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP"
|
||||
|
||||
private val internalMultipartSend = ThreadLocal.withInitial { false }
|
||||
private var queuedOriginalItemRepeatCount = 0
|
||||
private var queuedOriginalItemRepeatOverrideType: String? = null
|
||||
private var queuedOriginalItemRepeatSnapDurationMs: Int? = null
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
|
||||
// Notification & Loop Tracking
|
||||
private var totalRepeatCount = 0
|
||||
private var processedRepeatCount = 0
|
||||
private var currentRecipientName: String = "Unknown"
|
||||
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
queuedOriginalItemRepeatOverrideType = overrideType
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
queuedOriginalItemRepeatSnapDurationMs = snapDurationMs
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
private fun clearQueuedOriginalItemRepeats() {
|
||||
queuedOriginalItemRepeatCount = 0
|
||||
queuedOriginalItemRepeatOverrideType = null
|
||||
queuedOriginalItemRepeatSnapDurationMs = null
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
if (queuedOriginalItemRepeatCount <= 0) {
|
||||
private fun updateContinuousSendNotification(context: ModContext) {
|
||||
if (!engineActive.get()) return
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val remaining = queuedOriginalItemRepeatCount
|
||||
val processed = processedRepeatCount
|
||||
val total = totalRepeatCount
|
||||
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
|
||||
|
||||
if (!isWorking) {
|
||||
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
showCompletionNotification(context, processed, total)
|
||||
return
|
||||
}
|
||||
|
||||
val progressPercent = if (total > 0) (processed * 100) / total else 0
|
||||
|
||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(android.R.drawable.ic_popup_sync)
|
||||
.setColor(0xFF3498DB.toInt())
|
||||
.setContentTitle("Sending Snaps to $currentRecipientName")
|
||||
.setContentText("Progress: $processed / $total ($progressPercent%)")
|
||||
.setSubText("$processed / $total")
|
||||
.setProgress(total, processed, false)
|
||||
|
||||
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
|
||||
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
|
||||
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
|
||||
val isError = sent < total && !isStopped.get()
|
||||
val title = when {
|
||||
isStopped.get() -> "Continuous Send Stopped"
|
||||
isError -> "Continuous Send Failed"
|
||||
else -> "Continuous Send Finished"
|
||||
}
|
||||
val content = "Sent $sent / $total snaps to $currentRecipientName"
|
||||
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
|
||||
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
|
||||
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
|
||||
.setContentTitle(title)
|
||||
.setContentText(content)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
|
||||
val intent = Intent(action).setPackage(context.androidContext.packageName)
|
||||
return PendingIntent.getBroadcast(
|
||||
context.androidContext,
|
||||
action.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
|
||||
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
|
||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return false
|
||||
}
|
||||
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
|
||||
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
|
||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||
if (!result) {
|
||||
queuedOriginalItemRepeatCount++
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -112,6 +208,20 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
|
||||
private fun createContinuousNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CONTINUOUS_SEND_CHANNEL_ID,
|
||||
"Continuous Send",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
channel.description = "Progress status for continuous snap sending"
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -192,7 +302,35 @@ class SendOverride : Feature("Send Override") {
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
override fun init() {
|
||||
createNotificationChannel()
|
||||
|
||||
createContinuousNotificationChannel()
|
||||
|
||||
val actionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_PAUSE_RESUME -> {
|
||||
isPaused.set(!isPaused.get())
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
isStopped.set(true)
|
||||
if (isPaused.get()) {
|
||||
isPaused.set(false)
|
||||
}
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PAUSE_RESUME)
|
||||
addAction(ACTION_STOP)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
}
|
||||
|
||||
val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get()
|
||||
var postSavePolicy: Int? = null
|
||||
|
||||
@@ -727,10 +865,10 @@ class SendOverride : Feature("Send Override") {
|
||||
completionCallback: Any?
|
||||
): Boolean {
|
||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
||||
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
@@ -802,22 +940,54 @@ class SendOverride : Feature("Send Override") {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
context.coroutineScope.launch {
|
||||
while (isPaused.get() && !isStopped.get()) {
|
||||
delay(500)
|
||||
}
|
||||
if (isStopped.get()) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val callback = CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendIteration(index + 1)
|
||||
processedRepeatCount++
|
||||
queuedOriginalItemRepeatCount--
|
||||
updateContinuousSendNotification(context)
|
||||
|
||||
if (index < repeatCount - 1) {
|
||||
sendIteration(index + 1)
|
||||
} else {
|
||||
// Batch Finished: Trigger original Snapchat callback
|
||||
originalCallback?.let { cb ->
|
||||
runCatching {
|
||||
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
|
||||
if (method != null) {
|
||||
if (method.parameterCount == 0) {
|
||||
method.invoke(cb)
|
||||
} else {
|
||||
// Pass null for all required parameters to safely trigger the completion UI
|
||||
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
|
||||
}
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
if (index > 0) delay(1000) // Human-like delay
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,15 +1000,21 @@ class SendOverride : Feature("Send Override") {
|
||||
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
val queuedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
val resolvedOverrideType = queuedOverrideType
|
||||
?: configOverrideType?.takeIf { it != "always_ask" }
|
||||
val resolvedSnapDurationMs = if (queuedOverrideType != null) {
|
||||
MediaFilePicker.getQueuedOverrideSnapDurationMs()
|
||||
} else {
|
||||
10000
|
||||
}
|
||||
|
||||
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
|
||||
sendEvent.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||
val handledRepeat = if (!handledSplit) {
|
||||
handleQueuedOriginalItemRepeatSuccess()
|
||||
handleQueuedOriginalItemRepeatSuccess(context)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -858,7 +1034,7 @@ class SendOverride : Feature("Send Override") {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
if (sendMedia(resolvedOverrideType, resolvedSnapDurationMs)) {
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
return@subscribe
|
||||
@@ -866,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.runOnUiThread {
|
||||
val recipientNameForTask = recipientName
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
@@ -1274,6 +1449,11 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
Button(onClick = {
|
||||
val finalSelectedType = selectedType
|
||||
val selectedSnapDurationMs = if (finalSelectedType != "SAVEABLE_SNAP") {
|
||||
convertDuration(customDuration)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val repeatCount = if (continuousSendEnabled) {
|
||||
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
|
||||
} else {
|
||||
@@ -1295,13 +1475,13 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
|
||||
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(null)
|
||||
}
|
||||
return@Button
|
||||
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
@@ -1361,7 +1541,7 @@ class SendOverride : Feature("Send Override") {
|
||||
if (sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
selectedSnapDurationMs
|
||||
)) {
|
||||
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
|
||||
context.inAppOverlay.showStatusToast(
|
||||
@@ -1410,22 +1590,32 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
} else {
|
||||
if (repeatCount == 1) {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
} else if (MediaFilePicker.hasReusableOriginalItem()) {
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 1
|
||||
currentRecipientName = recipientNameForTask
|
||||
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification(context)
|
||||
}
|
||||
} else {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 0
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
currentRecipientName = recipientNameForTask
|
||||
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
selectedSnapDurationMs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
|
||||
it.messageId = uniqueMessageIdentifier
|
||||
it.conversationId = conversationId
|
||||
it.userId = event.message.senderId.toString()
|
||||
it.username = usernameCache.getOrPut(it.userId) {
|
||||
context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId
|
||||
}
|
||||
it.username = usernameCache[it.userId]
|
||||
?: context.database.getFriendInfo(it.userId)?.mutableUsername?.also { resolvedUsername ->
|
||||
usernameCache[it.userId] = resolvedUsername
|
||||
}
|
||||
?: it.userId
|
||||
it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
|
||||
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
|
||||
}
|
||||
it.groupTitle = groupTitleCache[conversationId]
|
||||
?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle ->
|
||||
groupTitleCache[conversationId] = resolvedGroupTitle
|
||||
}
|
||||
?: conversationId
|
||||
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,84 +3,36 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.HandlerThread
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import android.transition.Transition
|
||||
import android.util.Range
|
||||
import android.view.View
|
||||
import android.view.TextureView
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.Animation
|
||||
import android.widget.OverScroller
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import java.io.File
|
||||
import java.lang.Thread
|
||||
import java.lang.reflect.Method
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import okhttp3.Dispatcher
|
||||
import java.lang.Thread
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class PerformanceMode : Feature("Performance Mode") {
|
||||
companion object {
|
||||
private const val CHAT_FEED_CACHE_MAX_ROWS = 400
|
||||
private const val CHAT_FEED_CACHE_MAX_BLOB_BYTES = 512
|
||||
private const val CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS = 15_000L
|
||||
private const val CHAT_FEED_CACHE_MAX_AGE_MS = 5L * 60L * 1000L
|
||||
private const val CHAT_FEED_CACHE_SCHEMA_VERSION = 2
|
||||
private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64
|
||||
private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L
|
||||
private const val SNAP_PREFETCH_GROUP_MESSAGES = 48
|
||||
private const val SNAP_PREFETCH_DM_MESSAGES = 24
|
||||
private const val REOPEN_WARMUP_GROUP_MESSAGES = 160
|
||||
private const val REOPEN_WARMUP_DM_MESSAGES = 96
|
||||
}
|
||||
|
||||
private data class SnapshotCell(
|
||||
val type: Int,
|
||||
val stringValue: String? = null,
|
||||
val longValue: Long? = null,
|
||||
val doubleValue: Double? = null,
|
||||
val blobValue: String? = null,
|
||||
)
|
||||
|
||||
private data class CursorSnapshot(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<SnapshotCell>>,
|
||||
)
|
||||
|
||||
private data class ChatFeedSnapshotCache(
|
||||
val schemaVersion: Int,
|
||||
val queryKey: String,
|
||||
val createdAt: Long,
|
||||
val snapshot: CursorSnapshot,
|
||||
)
|
||||
|
||||
private data class MessageWindowState(
|
||||
val conversationId: String,
|
||||
val currentSize: Int,
|
||||
val oldestOrderKey: Long?,
|
||||
val newestOrderKey: Long?,
|
||||
val updatedAt: Long,
|
||||
val isGroup: Boolean,
|
||||
)
|
||||
|
||||
override fun init() {
|
||||
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
|
||||
val isMaxProfile = profile == "max"
|
||||
@@ -90,7 +42,6 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
Process.THREAD_PRIORITY_MORE_FAVORABLE
|
||||
}
|
||||
val minimumFrameRate = if (isMaxProfile) 60 else 45
|
||||
val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24
|
||||
val durationScale = if (isMaxProfile) 0.35f else 0.55f
|
||||
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
|
||||
val maxRequests = if (isMaxProfile) 192 else 96
|
||||
@@ -100,55 +51,19 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
|
||||
val maxScrollDurationMs = if (isMaxProfile) 72 else 180
|
||||
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
|
||||
val snapMapTransitionDurationMs = if (isMaxProfile) 0L else 24L
|
||||
val snapMapCameraDurationMs = if (isMaxProfile) 16L else 64L
|
||||
val snapMapMoveDurationMs = if (isMaxProfile) 8L else 40L
|
||||
val snapMapPrefetchZoomDelta = if (isMaxProfile) 6 else 3
|
||||
val preferredJavaThreadPriority = if (isMaxProfile) Thread.NORM_PRIORITY + 2 else Thread.NORM_PRIORITY + 1
|
||||
|
||||
context.log.info(
|
||||
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, minRecordingFps=$minimumRecordingFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate, snapMapTransitionMs=$snapMapTransitionDurationMs, snapMapCameraMs=$snapMapCameraDurationMs, snapMapMoveMs=$snapMapMoveDurationMs, snapMapPrefetchZoomDelta=$snapMapPrefetchZoomDelta, javaThreadPriority=$preferredJavaThreadPriority",
|
||||
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
|
||||
"PerformanceMode"
|
||||
)
|
||||
|
||||
runCatching {
|
||||
ValueAnimator.setFrameDelay(0L)
|
||||
context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode")
|
||||
}
|
||||
|
||||
fun firstHitLogger(name: String): (String) -> Unit {
|
||||
val didLog = AtomicBoolean(false)
|
||||
return { details ->
|
||||
if (didLog.compareAndSet(false, true)) {
|
||||
context.log.info("First hit: $name | $details", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor")
|
||||
val handlerThreadStartLog = firstHitLogger("HandlerThread.start")
|
||||
val threadStartLog = firstHitLogger("Thread.start")
|
||||
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
|
||||
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
|
||||
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
|
||||
val recyclerCtorLog = firstHitLogger("RecyclerView.constructor")
|
||||
val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter")
|
||||
val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager")
|
||||
val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase")
|
||||
val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase")
|
||||
val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate")
|
||||
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
|
||||
val mapDialogLog = firstHitLogger("Dialog.show")
|
||||
val mapViewLog = firstHitLogger("MapView.constructor")
|
||||
val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock")
|
||||
val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration")
|
||||
val mapThreadLog = firstHitLogger("SnapMap.mapThread")
|
||||
val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps")
|
||||
val mapTransitionLog = firstHitLogger("SnapMap.transitionOptions")
|
||||
val mapMoveLog = firstHitLogger("SnapMap.moveDuration")
|
||||
fun isPerformanceSensitiveThread(name: String?): Boolean {
|
||||
val normalizedName = name?.lowercase() ?: return false
|
||||
return listOf("codec", "transcod", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
|
||||
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
|
||||
normalizedName.contains(it)
|
||||
}
|
||||
}
|
||||
@@ -158,187 +73,11 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
return durationMs.coerceAtMost(maxDurationMs)
|
||||
}
|
||||
|
||||
val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() }
|
||||
val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json")
|
||||
val lastChatFeedSnapshotWrite = AtomicLong(0L)
|
||||
val chatFeedSnapshotServedThisProcess = AtomicBoolean(false)
|
||||
|
||||
fun invalidateChatFeedSnapshot(reason: String) {
|
||||
val deleted = runCatching {
|
||||
if (!chatFeedSnapshotFile.exists()) return@runCatching false
|
||||
chatFeedSnapshotFile.delete()
|
||||
}.getOrDefault(false)
|
||||
chatFeedSnapshotServedThisProcess.set(false)
|
||||
if (deleted) {
|
||||
context.log.info("Invalidated chat feed snapshot ($reason)", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
|
||||
Activity::class.java.hook("onResume", HookStage.AFTER) {
|
||||
if (!isMaxProfile) return@hook
|
||||
chatFeedSnapshotServedThisProcess.set(false)
|
||||
}
|
||||
|
||||
val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE)
|
||||
val messageWindowStates = runCatching {
|
||||
val raw = windowStatePrefs.getString("states", null).orEmpty()
|
||||
if (raw.isBlank()) {
|
||||
LinkedHashMap<String, MessageWindowState>()
|
||||
} else {
|
||||
context.gson.fromJson<LinkedHashMap<String, MessageWindowState>>(
|
||||
raw,
|
||||
object : TypeToken<LinkedHashMap<String, MessageWindowState>>() {}.type
|
||||
) ?: LinkedHashMap()
|
||||
}
|
||||
}.getOrElse { LinkedHashMap() }
|
||||
|
||||
fun persistMessageWindowStates() {
|
||||
runCatching {
|
||||
windowStatePrefs.edit().putString("states", context.gson.toJson(messageWindowStates)).apply()
|
||||
}.onFailure {
|
||||
context.log.error("Failed to persist message window states", it, "PerformanceMode")
|
||||
}
|
||||
}
|
||||
|
||||
val snapshotQueryWhitespaceRegex = Regex("\\s+")
|
||||
fun buildChatFeedSnapshotQueryKey(sql: String): String {
|
||||
return sql.lowercase()
|
||||
.replace(snapshotQueryWhitespaceRegex, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun isChatFeedQuery(sql: String): Boolean {
|
||||
val normalized = buildChatFeedSnapshotQueryKey(sql)
|
||||
if (!normalized.startsWith("select ")) return false
|
||||
|
||||
val isFriendsFeedViewQuery =
|
||||
normalized.startsWith("select * from friendsfeedview ") &&
|
||||
normalized.contains(" order by _id ") &&
|
||||
normalized.contains(" limit ")
|
||||
|
||||
val isFeedEntryQuery =
|
||||
normalized.startsWith("select * from feed_entry ") &&
|
||||
normalized.contains(" order by last_updated_timestamp desc ") &&
|
||||
normalized.contains(" limit ")
|
||||
|
||||
return (isFriendsFeedViewQuery || isFeedEntryQuery) &&
|
||||
!normalized.contains("count(") &&
|
||||
!normalized.contains("select 0") &&
|
||||
!normalized.contains("where key = ?") &&
|
||||
!normalized.contains("where client_conversation_id = ?")
|
||||
}
|
||||
|
||||
fun cursorCell(cursor: Cursor, index: Int): SnapshotCell {
|
||||
return when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_NULL -> SnapshotCell(Cursor.FIELD_TYPE_NULL)
|
||||
Cursor.FIELD_TYPE_INTEGER -> SnapshotCell(Cursor.FIELD_TYPE_INTEGER, longValue = cursor.getLong(index))
|
||||
Cursor.FIELD_TYPE_FLOAT -> SnapshotCell(Cursor.FIELD_TYPE_FLOAT, doubleValue = cursor.getDouble(index))
|
||||
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
|
||||
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
|
||||
Cursor.FIELD_TYPE_BLOB,
|
||||
blobValue = cursor.getBlob(index)
|
||||
?.takeIf { it.size <= CHAT_FEED_CACHE_MAX_BLOB_BYTES }
|
||||
?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
|
||||
)
|
||||
else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot? {
|
||||
val originalPosition = cursor.position
|
||||
val snapshot = runCatching {
|
||||
val columns = cursor.columnNames.toList()
|
||||
val rows = mutableListOf<List<SnapshotCell>>()
|
||||
if (cursor.moveToFirst()) {
|
||||
var rowCount = 0
|
||||
do {
|
||||
rows += columns.indices.map { index -> cursorCell(cursor, index) }
|
||||
rowCount++
|
||||
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
|
||||
}
|
||||
CursorSnapshot(columns, rows)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to snapshot chat feed cursor", it, "PerformanceMode")
|
||||
}.getOrNull()
|
||||
|
||||
runCatching { cursor.moveToPosition(originalPosition) }
|
||||
val restoredPosition = runCatching { cursor.position }.getOrNull()
|
||||
if (restoredPosition != originalPosition) {
|
||||
context.log.warn(
|
||||
"Skipping chat feed snapshot write due non-restorable cursor position (from=$originalPosition to=${restoredPosition ?: "unknown"})",
|
||||
"PerformanceMode"
|
||||
)
|
||||
return null
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
|
||||
return MatrixCursor(snapshot.columns.toTypedArray(), snapshot.rows.size).also { matrixCursor ->
|
||||
snapshot.rows.forEach { row ->
|
||||
matrixCursor.addRow(row.map { cell ->
|
||||
when (cell.type) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cell.longValue
|
||||
Cursor.FIELD_TYPE_FLOAT -> cell.doubleValue
|
||||
Cursor.FIELD_TYPE_BLOB -> cell.blobValue?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
else -> cell.stringValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readSnapshot(file: File, expectedQueryKey: String): CursorSnapshot? {
|
||||
return runCatching {
|
||||
if (!file.exists()) return null
|
||||
val cache = context.gson.fromJson(file.readText(Charsets.UTF_8), ChatFeedSnapshotCache::class.java) ?: return null
|
||||
if (cache.schemaVersion != CHAT_FEED_CACHE_SCHEMA_VERSION) {
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
if (cache.queryKey != expectedQueryKey) {
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
if (System.currentTimeMillis() - cache.createdAt > CHAT_FEED_CACHE_MAX_AGE_MS) {
|
||||
runCatching { file.delete() }
|
||||
return null
|
||||
}
|
||||
cache.snapshot
|
||||
}.getOrElse {
|
||||
runCatching { file.delete() }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeSnapshot(file: File, queryKey: String, snapshot: CursorSnapshot) {
|
||||
runCatching {
|
||||
file.writeText(
|
||||
context.gson.toJson(
|
||||
ChatFeedSnapshotCache(
|
||||
schemaVersion = CHAT_FEED_CACHE_SCHEMA_VERSION,
|
||||
queryKey = queryKey,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
snapshot = snapshot,
|
||||
)
|
||||
),
|
||||
Charsets.UTF_8
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode")
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!isMaxProfile) return@subscribe
|
||||
val url = event.url
|
||||
if (url.contains("ami/friends")) {
|
||||
invalidateChatFeedSnapshot("friends-mutation-sync")
|
||||
}
|
||||
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
|
||||
event.canceled = true
|
||||
mapboxNetworkBlockLog("url=$url")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +86,6 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
val threadName = param.argNullable<String>(0)
|
||||
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
|
||||
param.setArg(1, threadPriority)
|
||||
handlerThreadConstructorLog("name=$threadName priority=$threadPriority")
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
@@ -359,18 +97,13 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
Process.setThreadPriority(tid, threadPriority)
|
||||
}
|
||||
}
|
||||
handlerThreadStartLog("name=${thread.name} tid=${thread.threadId} priority=$threadPriority")
|
||||
}
|
||||
|
||||
Thread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.thisObject<Thread>()
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
thread.priority = preferredJavaThreadPriority
|
||||
}
|
||||
threadStartLog("name=${thread.name} priority=${thread.priority}")
|
||||
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
|
||||
mapThreadLog("name=${thread.name} priority=${thread.priority}")
|
||||
thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +116,6 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
}
|
||||
executor.allowCoreThreadTimeOut(false)
|
||||
executor.prestartAllCoreThreads()
|
||||
executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,32 +124,62 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
runCatching {
|
||||
dispatcher.maxRequests = maxRequests
|
||||
dispatcher.maxRequestsPerHost = maxRequestsPerHost
|
||||
dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}")
|
||||
}
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
|
||||
param.setResult(durationScale)
|
||||
animatorLog("durationScale=$durationScale")
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
Transition::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
Animation::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerCtorLog("cache=$recyclerViewCacheSize max=$isMaxProfile class=${recyclerView::class.java.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable<Any>(0)?.javaClass?.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
|
||||
@@ -426,14 +188,13 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.initialPrefetchItemCount = prefetchItemCount
|
||||
layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12)
|
||||
}
|
||||
is StaggeredGridLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
|
||||
}
|
||||
}
|
||||
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.applyPerformancePragmas() {
|
||||
@@ -446,28 +207,21 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteOpenLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteCreateLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
|
||||
}
|
||||
|
||||
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
|
||||
val currentRate = param.arg<Int>(0)
|
||||
val applied = currentRate
|
||||
.coerceAtLeast(minimumRecordingFrameRate)
|
||||
.coerceAtLeast(if (isMaxProfile) 30 else 24)
|
||||
.coerceAtMost(if (isMaxProfile) 60 else 45)
|
||||
if (applied != currentRate) {
|
||||
param.setArg(0, applied)
|
||||
}
|
||||
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
|
||||
@@ -477,7 +231,6 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
if (updated != original) {
|
||||
param.setArg(4, updated)
|
||||
}
|
||||
overScrollerLog("requested=$original applied=${param.arg<Int>(4)}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,46 +243,6 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
}
|
||||
}
|
||||
|
||||
fun applyActivityPerformanceTuning(activity: Activity) {
|
||||
runCatching {
|
||||
activity.window.setWindowAnimations(0)
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
applyActivityPerformanceTuning(it)
|
||||
}
|
||||
|
||||
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
|
||||
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
|
||||
val window = dialog.window ?: return@hook
|
||||
runCatching {
|
||||
window.setWindowAnimations(0)
|
||||
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
|
||||
mapDialogLog("class=${dialog::class.java.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
|
||||
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
|
||||
mapView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
mapViewLog("class=${mapView::class.java.name}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.renderer.MapRenderer").hook("setMaximumFps", HookStage.BEFORE) { param ->
|
||||
val requested = param.arg<Int>(0)
|
||||
val applied = requested.coerceAtLeast(120)
|
||||
if (applied != requested) {
|
||||
param.setArg(0, applied)
|
||||
}
|
||||
mapRendererFpsLog("requested=$requested applied=${param.arg<Int>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
|
||||
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
|
||||
@@ -546,9 +259,9 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
}
|
||||
|
||||
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
|
||||
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
|
||||
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
|
||||
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
|
||||
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
|
||||
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
|
||||
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
|
||||
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
|
||||
|
||||
@@ -556,15 +269,14 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
val nativeMapView = param.thisObject<Any>()
|
||||
runCatching {
|
||||
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
|
||||
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, snapMapPrefetchZoomDelta)
|
||||
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6)
|
||||
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
|
||||
nativeSetTransitionDuration?.invoke(nativeMapView, snapMapTransitionDurationMs)
|
||||
nativeSetTransitionDuration?.invoke(nativeMapView, 0L)
|
||||
nativeSetTransitionOptions?.invoke(
|
||||
nativeMapView,
|
||||
transitionOptionsCtor.newInstance(snapMapTransitionDurationMs, 0L, false)
|
||||
transitionOptionsCtor.newInstance(0L, 0L, false)
|
||||
)
|
||||
nativeCancelTransitions?.invoke(nativeMapView)
|
||||
mapTransitionLog("transitionMs=$snapMapTransitionDurationMs prefetchZoomDelta=$snapMapPrefetchZoomDelta placementTransitions=false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,12 +286,11 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
method.parameterTypes.last() == Long::class.javaPrimitiveType
|
||||
}?.hook(HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(5)
|
||||
val applied = clampPositiveDuration(original, snapMapCameraDurationMs)
|
||||
val applied = original.coerceAtMost(16L)
|
||||
if (applied != original) {
|
||||
param.setArg(5, applied)
|
||||
}
|
||||
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
|
||||
mapCameraAnimLog("requested=$original applied=${param.arg<Long>(5)}")
|
||||
}
|
||||
|
||||
nativeMapViewClass.findRestrictedMethod { method ->
|
||||
@@ -590,125 +301,57 @@ class PerformanceMode : Feature("Performance Mode") {
|
||||
method.parameterTypes[2] == Long::class.javaPrimitiveType
|
||||
}?.hook(HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(2)
|
||||
val applied = clampPositiveDuration(original, snapMapMoveDurationMs)
|
||||
val applied = original.coerceAtMost(8L)
|
||||
if (applied != original) {
|
||||
param.setArg(2, applied)
|
||||
}
|
||||
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
|
||||
mapMoveLog("requested=$original applied=${param.arg<Long>(2)}")
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.snapchat.client.messaging.MessageWindowManager\$CppProxy").hook("initWindow", HookStage.BEFORE) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
|
||||
val conversationId = runCatching {
|
||||
SnapUUID(param.arg(0)).toString()
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@hook
|
||||
|
||||
val initParams = param.arg<Any>(1)
|
||||
val conversationType = context.database.getConversationType(conversationId) ?: return@hook
|
||||
val isGroup = conversationType == 1
|
||||
val savedState = synchronized(messageWindowStates) {
|
||||
messageWindowStates[conversationId]
|
||||
?.takeIf { System.currentTimeMillis() - it.updatedAt <= MESSAGE_WINDOW_STATE_MAX_AGE_MS }
|
||||
}
|
||||
|
||||
val enumConstants = initParams.getObjectField("mStartingType")?.javaClass?.enumConstants ?: return@hook
|
||||
if (savedState != null) {
|
||||
val restoredMaxSize = if (savedState.isGroup) {
|
||||
savedState.currentSize.coerceAtLeast(220).coerceAtMost(520)
|
||||
} else {
|
||||
savedState.currentSize.coerceAtLeast(140).coerceAtMost(320)
|
||||
}
|
||||
val restoredForward = (savedState.currentSize + if (savedState.isGroup) 24 else 16).coerceAtMost(restoredMaxSize)
|
||||
val restoredBack = if (savedState.isGroup) 180 else 120
|
||||
initParams.setObjectField("mStartingType", enumConstants.firstOrNull { it.toString() == "MESSAGE" } ?: return@hook)
|
||||
initParams.setObjectField("mStartingOrderKey", savedState.oldestOrderKey ?: savedState.newestOrderKey)
|
||||
initParams.setObjectField("mMaxSize", restoredMaxSize)
|
||||
initParams.setObjectField("mNumMessagesForward", restoredForward)
|
||||
initParams.setObjectField("mNumMessagesBack", restoredBack)
|
||||
|
||||
val warmupAmount = if (savedState.isGroup) REOPEN_WARMUP_GROUP_MESSAGES else REOPEN_WARMUP_DM_MESSAGES
|
||||
val oldestKey = savedState.oldestOrderKey
|
||||
if (oldestKey != null) {
|
||||
context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(
|
||||
conversationId = conversationId,
|
||||
lastMessageId = oldestKey,
|
||||
amount = warmupAmount,
|
||||
onSuccess = {},
|
||||
onError = {}
|
||||
)
|
||||
}
|
||||
fun applyActivityPerformanceTuning(activity: Activity) {
|
||||
runCatching {
|
||||
activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
val display = activity.display
|
||||
val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate
|
||||
?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate
|
||||
activity.window.attributes = activity.window.attributes.apply {
|
||||
this.preferredRefreshRate = targetRefreshRate
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install saved message window restore hooks", it, "PerformanceMode")
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
callbacks.getClass("MessageWindowManagerDelegate")?.hook("onWindowUpdated", HookStage.AFTER) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
|
||||
val conversationId = runCatching { SnapUUID(param.arg(0)).toString() }.getOrNull() ?: return@hook
|
||||
val update = param.arg<Any>(2)
|
||||
val pagination = update.getObjectField("mPagination") ?: return@hook
|
||||
val currentSize = pagination.getObjectField("mCurrentSize") as? Int ?: return@hook
|
||||
val oldestOrderKey = pagination.getObjectField("mOldestOrderKey") as? Long
|
||||
val newestOrderKey = pagination.getObjectField("mNewestOrderKey") as? Long
|
||||
val conversationType = context.database.getConversationType(conversationId) ?: 0
|
||||
val isGroup = conversationType == 1
|
||||
|
||||
synchronized(messageWindowStates) {
|
||||
messageWindowStates[conversationId] = MessageWindowState(
|
||||
conversationId = conversationId,
|
||||
currentSize = currentSize.coerceAtMost(if (isGroup) 420 else 260),
|
||||
oldestOrderKey = oldestOrderKey,
|
||||
newestOrderKey = newestOrderKey,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
isGroup = isGroup
|
||||
)
|
||||
while (messageWindowStates.size > MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS) {
|
||||
val eldestKey = messageWindowStates.entries.minByOrNull { it.value.updatedAt }?.key ?: break
|
||||
messageWindowStates.remove(eldestKey)
|
||||
}
|
||||
persistMessageWindowStates()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
|
||||
runCatching {
|
||||
activity.window.setSustainedPerformanceMode(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
val sql = param.argNullable<String>(1) ?: return@hook
|
||||
if (!isChatFeedQuery(sql)) return@hook
|
||||
if (chatFeedSnapshotServedThisProcess.get()) return@hook
|
||||
val queryKey = buildChatFeedSnapshotQueryKey(sql)
|
||||
readSnapshot(chatFeedSnapshotFile, queryKey)?.let { snapshot ->
|
||||
param.setResult(snapshotToMatrixCursor(snapshot))
|
||||
chatFeedSnapshotServedThisProcess.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.AFTER) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
val sql = param.argNullable<String>(1) ?: return@hook
|
||||
if (!isChatFeedQuery(sql)) return@hook
|
||||
val cursor = param.getResult() as? Cursor ?: return@hook
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastChatFeedSnapshotWrite.get() < CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS) return@hook
|
||||
val queryKey = buildChatFeedSnapshotQueryKey(sql)
|
||||
val snapshot = snapshotFromCursor(cursor) ?: return@hook
|
||||
if (snapshot.rows.isEmpty()) return@hook
|
||||
writeSnapshot(chatFeedSnapshotFile, queryKey, snapshot)
|
||||
lastChatFeedSnapshotWrite.set(now)
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
|
||||
onNextActivityCreate {
|
||||
applyActivityPerformanceTuning(it)
|
||||
}
|
||||
|
||||
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
|
||||
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
|
||||
val window = dialog.window ?: return@hook
|
||||
runCatching {
|
||||
window.setWindowAnimations(0)
|
||||
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
window.attributes = window.attributes.apply {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val textureView = param.thisObject<TextureView>()
|
||||
runCatching {
|
||||
// Universal Guard: Only accelerate views owned by Snapchat.
|
||||
// This prevents crashes in native hardware providers across all devices.
|
||||
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
|
||||
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.widget.TextView
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.MathContext
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.util.Locale
|
||||
|
||||
class FakeFollowersCount : Feature("Fake Followers Count") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.userInterface.spoofFollowersCount.globalState != true) return
|
||||
val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
val digits = raw.replace(Regex("[^0-9]"), "")
|
||||
if (digits.isEmpty()) return
|
||||
val followerValue = try {
|
||||
BigInteger(digits)
|
||||
} catch (_: NumberFormatException) {
|
||||
return
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
|
||||
val textView = param.thisObject() as? TextView ?: return@hook
|
||||
if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook
|
||||
val arg0 = param.argNullable<Any>(0) ?: return@hook
|
||||
if (arg0 !is CharSequence) return@hook
|
||||
val text = arg0.toString()
|
||||
if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook
|
||||
val display = formatFollowersDisplay(followerValue)
|
||||
val replaced = FOLLOWERS_LINE.replace(text) { mr ->
|
||||
"$display ${mr.groupValues[2]}"
|
||||
}
|
||||
param.setArg(0, replaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFollowersDisplay(n: BigInteger): String {
|
||||
val v = n.max(BigInteger.ZERO)
|
||||
if (v < TEN_THOUSAND) {
|
||||
return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong())
|
||||
}
|
||||
val (scaled, suffix) = scaleToTier(v)
|
||||
return formatMantissaMaxFourDigits(scaled) + suffix
|
||||
}
|
||||
|
||||
private fun scaleToTier(v: BigInteger): Pair<BigDecimal, String> {
|
||||
var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0)
|
||||
var floor = SCALE_TIERS[i].floor
|
||||
var suffix = SCALE_TIERS[i].suffix
|
||||
var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) {
|
||||
i++
|
||||
floor = SCALE_TIERS[i].floor
|
||||
suffix = SCALE_TIERS[i].suffix
|
||||
scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
|
||||
}
|
||||
return Pair(scaled, suffix)
|
||||
}
|
||||
|
||||
private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String {
|
||||
val x = scaled.abs().setScale(12, RoundingMode.HALF_UP)
|
||||
if (x.compareTo(BigDecimal.ZERO) == 0) return "0"
|
||||
if (x >= HUNDRED) {
|
||||
val i = x.setScale(0, RoundingMode.HALF_UP)
|
||||
var s = i.toPlainString()
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
if (x >= TEN) {
|
||||
var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP))
|
||||
if (digitCount(s) > 4) {
|
||||
s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
|
||||
}
|
||||
if (digitCount(s) > 4) {
|
||||
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fun digitCount(s: String) = s.count { it.isDigit() }
|
||||
|
||||
private fun stripFrac(d: BigDecimal): String {
|
||||
var s = d.stripTrailingZeros().toPlainString()
|
||||
if ('.' in s) {
|
||||
s = s.trimEnd('0').trimEnd('.')
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private data class ScaleTier(val floor: BigInteger, val suffix: String)
|
||||
|
||||
companion object {
|
||||
private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE)
|
||||
private val COMPOSER_SNAP_TEXT_VIEW = setOf(
|
||||
"com.snap.valdi.views.ComposerSnapTextView",
|
||||
"com.snap.composer.views.ComposerSnapTextView",
|
||||
)
|
||||
private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP)
|
||||
private val TEN_THOUSAND = BigInteger("10000")
|
||||
private val THOUSAND_BD = BigDecimal("1000")
|
||||
private val HUNDRED = BigDecimal("100")
|
||||
private val TEN = BigDecimal("10")
|
||||
private val SCALE_TIERS = listOf(
|
||||
ScaleTier(BigInteger("1000"), "K"),
|
||||
ScaleTier(BigInteger("1000000"), "M"),
|
||||
ScaleTier(BigInteger("1000000000"), "B"),
|
||||
ScaleTier(BigInteger("1000000000000"), "T"),
|
||||
ScaleTier(BigInteger("1000000000000000"), "Q"),
|
||||
ScaleTier(BigInteger("1000000000000000000"), "E"),
|
||||
ScaleTier(BigInteger("1000000000000000000000"), "Z"),
|
||||
ScaleTier(BigInteger("1000000000000000000000000"), "Y"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import android.text.TextPaint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -78,9 +80,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
val ffSdlPrimaryTextStartMargin = 6 * density
|
||||
|
||||
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt()
|
||||
val separatorHeight = (density * 2).toInt()
|
||||
val safetyGap = (6 * density).toInt()
|
||||
val textPaint = TextPaint().apply {
|
||||
textSize = secondaryTextSize
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
context.event.subscribe(BuildMessageEvent::class) { param ->
|
||||
@@ -105,14 +108,15 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
}
|
||||
|
||||
fetchMessages(conversationId) {
|
||||
var maxTextHeight = 0
|
||||
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
|
||||
val rect = Rect()
|
||||
textPaint.getTextBounds(msg, 0, msg.length, rect)
|
||||
rect.height().also {
|
||||
if (it > maxTextHeight) maxTextHeight = it
|
||||
}.plus(separatorHeight)
|
||||
} ?: run {
|
||||
val universalTextSize = 12 * density
|
||||
val fontMetrics = textPaint.apply { textSize = universalTextSize }.fontMetrics
|
||||
val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt()
|
||||
val spacing = (4 * density).toInt()
|
||||
|
||||
val messages = messageCache[conversationId]
|
||||
val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing))
|
||||
|
||||
if (previewContainerHeight == 0) {
|
||||
ffItem.layoutParams = ffItem.layoutParams.apply {
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
@@ -120,22 +124,23 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
|
||||
}
|
||||
|
||||
ffItem.layoutParams = ffItem.layoutParams.apply {
|
||||
height = feedEntryHeight + previewContainerHeight + separatorHeight
|
||||
height = feedEntryHeight + (safetyGap).toInt() + previewContainerHeight
|
||||
}
|
||||
|
||||
cachedLayouts[conversationId] = frameLayout
|
||||
|
||||
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
|
||||
override fun draw(canvas: Canvas, paint: Paint) {
|
||||
val offsetY = canvas.height.toFloat() - previewContainerHeight
|
||||
paint.textSize = secondaryTextSize
|
||||
paint.color = context.userInterface.colorPrimary
|
||||
val startY = feedEntryHeight.toFloat() - (9 * density)
|
||||
paint.textSize = universalTextSize
|
||||
paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.85f).toArgb()
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
paint.isAntiAlias = true
|
||||
|
||||
messageCache[conversationId]?.forEachIndexed { index, messageString ->
|
||||
messages?.forEachIndexed { index, messageString ->
|
||||
canvas.drawText(messageString,
|
||||
feedEntryHeight + ffSdlPrimaryTextStartMargin,
|
||||
offsetY + index * maxTextHeight,
|
||||
ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 3),
|
||||
startY + (index + 1) * lineHeight + (index * spacing),
|
||||
paint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.View
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.ArrayList
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
|
||||
@Volatile
|
||||
private var cachedRuleIds: Set<String> = emptySet()
|
||||
|
||||
@Volatile
|
||||
private var cachedRuleIdsAt = 0L
|
||||
|
||||
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
|
||||
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
|
||||
private var lastRuleIdsHash = 0
|
||||
|
||||
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
|
||||
from("mFeedEntryIdentifier") {
|
||||
set("mConversationId", conversationIdInstance)
|
||||
@@ -19,10 +33,59 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
set("mReason", "CLEAR_CONVERSATION")
|
||||
}
|
||||
|
||||
private fun filterFriendFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
|
||||
private fun getRuleIdsSnapshot(): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - cachedRuleIdsAt <= 1_000L) return cachedRuleIds
|
||||
|
||||
return context.bridgeClient.getRuleIds(ruleType).toSet().also {
|
||||
cachedRuleIds = it
|
||||
cachedRuleIdsAt = now
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveRuleTargets(conversationId: String): Set<String> {
|
||||
return conversationTargetsCache.getOrPut(conversationId) {
|
||||
val targets = linkedSetOf(conversationId)
|
||||
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
|
||||
entry.friendUserId?.let { targets.add(it) }
|
||||
entry.participants?.forEach { targets.add(it) }
|
||||
}
|
||||
targets
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldHideConversation(
|
||||
conversationId: String,
|
||||
ruleIds: Set<String>,
|
||||
ruleState: RuleState?
|
||||
): Boolean {
|
||||
if (ruleState == null) return false
|
||||
|
||||
// Industrial Cache Gating: Clear decisions if the master rule list changed
|
||||
val currentHash = ruleIds.hashCode()
|
||||
if (currentHash != lastRuleIdsHash) {
|
||||
hideDecisionCache.clear()
|
||||
lastRuleIdsHash = currentHash
|
||||
}
|
||||
|
||||
return hideDecisionCache.getOrPut(conversationId) {
|
||||
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
|
||||
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterFriendFeed(
|
||||
entries: ArrayList<Any>,
|
||||
ruleIds: Set<String>,
|
||||
ruleState: RuleState?,
|
||||
deletedEntries: ArrayList<Any>? = null
|
||||
) {
|
||||
if (ruleState == null || entries.isEmpty()) return
|
||||
entries.removeIf { feedEntry ->
|
||||
val conversationIdInstance = feedEntry.getObjectField("mConversationId") ?: return@removeIf false
|
||||
if (canUseRule(SnapUUID(conversationIdInstance).toString())) {
|
||||
val conversationId = SnapUUID(conversationIdInstance).toString()
|
||||
if (shouldHideConversation(conversationId, ruleIds, ruleState)) {
|
||||
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)!!)
|
||||
true
|
||||
} else {
|
||||
@@ -31,6 +94,20 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideBoundChatFeedRow(view: View) {
|
||||
var current: View? = view
|
||||
repeat(4) {
|
||||
val parent = current?.parent as? View
|
||||
// Safety: Never hide the actual list container
|
||||
if (parent?.javaClass?.name?.contains("RecyclerView") == true) {
|
||||
current?.hideViewCompletely()
|
||||
return
|
||||
}
|
||||
current?.hideViewCompletely()
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookCallbackMethod(
|
||||
hookedCallbacks: MutableSet<String>,
|
||||
callbackClassName: String,
|
||||
@@ -51,6 +128,14 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
override fun init() {
|
||||
if (!context.config.userInterface.hideFriendFeedEntry.get()) return
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.friendFeedItem { conversationId ->
|
||||
if (shouldHideConversation(conversationId, getRuleIdsSnapshot(), getRuleState())) {
|
||||
hideBoundChatFeedRow(event.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
classLoader = context.androidContext.classLoader
|
||||
val hasFetchAndSyncCallback = callbacks.getAsMap()?.entries?.any {
|
||||
@@ -69,13 +154,13 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
when {
|
||||
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchAndSyncFeedComplete") { param ->
|
||||
val deletedConversations: ArrayList<Any> = param.arg(2)
|
||||
filterFriendFeed(param.arg(0), deletedConversations)
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
val deletedConversations = param.argNullable<ArrayList<Any>>(2)
|
||||
val ruleIds = getRuleIdsSnapshot()
|
||||
val ruleState = getRuleState()
|
||||
filterFriendFeed(entries, ruleIds, ruleState, deletedConversations)
|
||||
|
||||
if (deletedConversations.any {
|
||||
val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString()
|
||||
context.database.getFeedEntryByConversationId(uuid) != null
|
||||
}) {
|
||||
if (deletedConversations?.isNotEmpty() == true) {
|
||||
param.setArg(4, true)
|
||||
}
|
||||
}
|
||||
@@ -83,34 +168,40 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
|
||||
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onSyncFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0), param.argNullable(2))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState(), param.argNullable(2))
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedEntriesComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onQueryFeedComplete") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
|
||||
callbackName == "FeedManagerDelegate" -> {
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFeedEntriesUpdated") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onInternalSyncFeed") { param ->
|
||||
filterFriendFeed(param.arg(0))
|
||||
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
|
||||
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -25,6 +32,47 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.AppleLogo
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
private fun GradientIcon(
|
||||
imageVector: ImageVector,
|
||||
brush: Brush,
|
||||
size: androidx.compose.ui.unit.Dp = 16.dp
|
||||
) {
|
||||
Image(
|
||||
imageVector = imageVector,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.graphicsLayer(alpha = 0.99f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GradientText(
|
||||
text: String,
|
||||
brush: Brush,
|
||||
fontWeight: FontWeight,
|
||||
fontSize: androidx.compose.ui.unit.TextUnit
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(alpha = 0.99f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class MessageIndicators : Feature("Message Indicators") {
|
||||
override fun init() {
|
||||
val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return
|
||||
@@ -35,28 +83,43 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
val appleLogo = AppleLogo
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.chatMessage { _, _ ->
|
||||
event.chatMessage { conversationId, _ ->
|
||||
val view = event.view as? ViewGroup ?: return@subscribe
|
||||
view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) }
|
||||
|
||||
val message = event.databaseMessage ?: return@chatMessage
|
||||
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
|
||||
if (message.senderId == context.database.myUserId && messageIndicatorsConfig.contains("skip_own_indicators")) return@chatMessage
|
||||
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
|
||||
val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2
|
||||
if (isGroupConversation && messageIndicatorsConfig.contains("disable_indicators_in_groups")) return@chatMessage
|
||||
|
||||
createComposeView(event.view.context) {
|
||||
val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9)))
|
||||
val locationBrush = Brush.linearGradient(listOf(Color(0xFFFF4B5C), Color(0xFFFFB74D)))
|
||||
val androidBrush = Brush.linearGradient(listOf(Color(0xFF3DDC84), Color(0xFFA5E635)))
|
||||
val appleBrush = Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFF94A3B8)))
|
||||
val webBrush = Brush.linearGradient(listOf(Color(0xFF4FC3F7), Color(0xFF00E5FF)))
|
||||
val directorBrush = Brush.linearGradient(listOf(Color(0xFFFFB74D), Color(0xFFFF5E3A)))
|
||||
val ovfBrush = Brush.linearGradient(listOf(Color(0xFFFF5CA8), Color(0xFFA64CFF)))
|
||||
val memoriesBrush = Brush.linearGradient(listOf(Color(0xFFFFE066), Color(0xFFFFB300)))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.padding(top = 4.dp, end = 1.dp),
|
||||
.padding(top = 6.dp, end = 6.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
|
||||
// Strictly detect Private Fidelius Wrap (1-on-1 private snaps)
|
||||
reader.containsPath(4, 4, 1, 1) ||
|
||||
reader.containsPath(4, 4, 1, 1, 1) ||
|
||||
reader.getByteArray(4, 3, 3) != null ||
|
||||
reader.containsPath(3, 99, 3)
|
||||
if (reader.containsPath(4, 4, 1, 1)
|
||||
|| reader.containsPath(4, 4, 1, 1, 1)
|
||||
|| reader.getByteArray(4, 3, 3) != null
|
||||
|| reader.containsPath(3, 99, 3)) {
|
||||
return@rememberAsyncMutableState true
|
||||
}
|
||||
if (reader.containsPath(4, 5, 1, 3, 1)) return@rememberAsyncMutableState true
|
||||
reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L)
|
||||
}
|
||||
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
|
||||
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
|
||||
@@ -75,52 +138,41 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
|
||||
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
|
||||
}
|
||||
val sentFromMemories by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getVarInt(4, 18) != null
|
||||
|| reader.getString(4, 5, 1, 2)?.contains("/h/") == true
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.LocationOn, locationBrush)
|
||||
}
|
||||
if (messageIndicatorsConfig.contains("platform_indicator")) {
|
||||
Image(
|
||||
imageVector = when {
|
||||
sentFromWebApp -> Icons.Default.Laptop
|
||||
sentFromIosDevice -> appleLogo
|
||||
else -> Icons.Default.Android
|
||||
},
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
val (platformIcon, platformBrush) = when {
|
||||
sentFromWebApp -> Icons.Default.Laptop to webBrush
|
||||
sentFromIosDevice -> appleLogo to appleBrush
|
||||
else -> Icons.Default.Android to androidBrush
|
||||
}
|
||||
GradientIcon(platformIcon, platformBrush)
|
||||
}
|
||||
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Lock,
|
||||
colorFilter = ColorFilter.tint(Color.Green),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.Lock, lockBrush)
|
||||
}
|
||||
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Edit,
|
||||
colorFilter = ColorFilter.tint(Color.Red),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
GradientIcon(Icons.Default.Edit, directorBrush)
|
||||
}
|
||||
if (sentFromMemories && messageIndicatorsConfig.contains("memories_indicator")) {
|
||||
GradientIcon(Icons.Default.HistoryEdu, memoriesBrush)
|
||||
}
|
||||
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
|
||||
Text(
|
||||
GradientText(
|
||||
text = "OVF",
|
||||
color = Color.Red,
|
||||
brush = ovfBrush,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 10.sp,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,47 +7,112 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.Collections
|
||||
|
||||
class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) {
|
||||
companion object {
|
||||
// 3-year offset for persistent local conversation sorting
|
||||
private const val PIN_OFFSET = 100000000000L
|
||||
}
|
||||
|
||||
private fun forcePinsInFeed(entries: ArrayList<Any>) {
|
||||
val now = System.currentTimeMillis()
|
||||
// Capture stable timestamp once to prevent jitter during the sweep
|
||||
val stableTimestamp = now + PIN_OFFSET
|
||||
|
||||
entries.forEach { entry ->
|
||||
val conversationIdObject = entry.getObjectFieldOrNull("mConversationId") ?: return@forEach
|
||||
runCatching {
|
||||
val conversationUUID = SnapUUID(conversationIdObject)
|
||||
if (getState(conversationUUID.toString())) {
|
||||
// Apply identical timestamp lead to all pinned items
|
||||
entry.setObjectField("mPinnedTimestampMs", stableTimestamp)
|
||||
} else {
|
||||
// Reset timestamp if it's currently a "Future" timestamp but shouldn't be pinned
|
||||
val currentTs = entry.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
|
||||
if (currentTs > now + (PIN_OFFSET / 2)) {
|
||||
entry.setObjectField("mPinnedTimestampMs", now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sort to ensure stable UI transition and prevent list jumping
|
||||
runCatching {
|
||||
Collections.sort(entries) { a, b ->
|
||||
val tsA = a.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
|
||||
val tsB = b.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
|
||||
tsB.compareTo(tsA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.messaging.unlimitedConversationPinning.get()) return
|
||||
|
||||
// Intercept native pinning requests and bypass server-side limits
|
||||
context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param ->
|
||||
val conversationUUID = SnapUUID(param.arg(0))
|
||||
val isPinned = param.arg<Any>(1).toString() == "PINNED"
|
||||
setState(conversationUUID.toString(), isPinned)
|
||||
|
||||
// Callback forcing to suppress "Can't pin conversation" errors for both PIN and UNPIN
|
||||
val callback = param.arg<Any>(2)
|
||||
mutableSetOf<() -> Unit>().apply {
|
||||
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onSuccess", HookStage.BEFORE) {
|
||||
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onSuccess", HookStage.BEFORE) {
|
||||
forEach { it() }
|
||||
})
|
||||
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onError", HookStage.BEFORE) { methodParam ->
|
||||
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onError", HookStage.BEFORE) { methodParam ->
|
||||
methodParam.setResult(null)
|
||||
// Manually trigger success to bypass server-side limit rejections
|
||||
callback::class.java.getDeclaredMethod("onSuccess").invoke(callback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val conversationUUID = SnapUUID(instance.getObjectField("mConversationId"))
|
||||
if (getState(conversationUUID.toString())) {
|
||||
instance.setObjectField("mPinnedTimestampMs", 1L)
|
||||
// Active feed sweep to ensure pinned conversations remain at the top
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
val callbackMap = callbacks.getAsMap().orEmpty()
|
||||
callbackMap.entries.forEach { (_, className) ->
|
||||
val clazz = runCatching { findClass(className!!) }.getOrNull() ?: return@forEach
|
||||
clazz.methods.forEach { method ->
|
||||
if (method.name.startsWith("on") && method.name.endsWith("Complete") && method.parameterTypes.any { it == ArrayList::class.java }) {
|
||||
clazz.hook(method.name, HookStage.BEFORE) { param ->
|
||||
(param.args().firstOrNull { it is ArrayList<*> } as? ArrayList<Any>)?.let { forcePinsInFeed(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pinning lead to newly created conversation objects
|
||||
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor
|
||||
runCatching {
|
||||
val conversationUUID = SnapUUID(conversationIdObject)
|
||||
if (getState(conversationUUID.toString())) {
|
||||
instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pinning lead to newly created feed entry objects
|
||||
context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor)
|
||||
val isPinned = getState(conversationUUID.toString())
|
||||
if (isPinned) {
|
||||
instance.setObjectField("mPinnedTimestampMs", 1L)
|
||||
val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor
|
||||
runCatching {
|
||||
val conversationUUID = SnapUUID(conversationIdObject)
|
||||
if (getState(conversationUUID.toString())) {
|
||||
instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRuleState() = RuleState.WHITELIST
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ fun View.onAttachChange(onAttach: (View.OnAttachStateChangeListener) -> Unit = {
|
||||
|
||||
fun View.hideViewCompletely() {
|
||||
fun hide() {
|
||||
if (visibility == View.GONE && layoutParams?.width == 0 && layoutParams?.height == 0) return
|
||||
|
||||
isEnabled = false
|
||||
visibility = View.GONE
|
||||
setWillNotDraw(true)
|
||||
|
||||
@@ -12,11 +12,12 @@ import java.net.SocketException
|
||||
import java.util.Locale
|
||||
import java.util.StringTokenizer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.random.Random
|
||||
|
||||
class HttpServer(
|
||||
private val timeout: Int = 10000
|
||||
private val timeout: Int = 15000 // Optimized: 15s Middle Ground
|
||||
) {
|
||||
private fun newRandomPort() = Random.nextInt(10000, 65535)
|
||||
|
||||
@@ -53,18 +54,24 @@ class HttpServer(
|
||||
AbstractLogger.directDebug("Starting http server on port $port")
|
||||
for (i in 0..5) {
|
||||
try {
|
||||
serverSocket = ServerSocket(port)
|
||||
serverSocket = ServerSocket(port).apply {
|
||||
soTimeout = timeout + 5000
|
||||
}
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
AbstractLogger.directError("failed to start http server on port $port", e)
|
||||
port = newRandomPort()
|
||||
}
|
||||
}
|
||||
continuation.resumeWith(Result.success(if (serverSocket == null) null.also {
|
||||
|
||||
if (serverSocket == null) {
|
||||
continuation.resume(null)
|
||||
return@launch
|
||||
} else this@HttpServer))
|
||||
}
|
||||
|
||||
continuation.resume(this@HttpServer)
|
||||
|
||||
while (!serverSocket!!.isClosed) {
|
||||
while (isActive && serverSocket?.isClosed == false) {
|
||||
try {
|
||||
val socket = serverSocket!!.accept()
|
||||
timeoutJob?.cancel()
|
||||
@@ -77,14 +84,12 @@ class HttpServer(
|
||||
socketJob?.cancel()
|
||||
socket.close()
|
||||
serverSocket?.close()
|
||||
}.onFailure {
|
||||
AbstractLogger.directError("failed to close socket", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SocketException) {
|
||||
AbstractLogger.directDebug("http server timed out")
|
||||
break;
|
||||
AbstractLogger.directDebug("http server timed out or closed")
|
||||
break
|
||||
} catch (e: Throwable) {
|
||||
AbstractLogger.directError("failed to handle request", e)
|
||||
}
|
||||
@@ -96,8 +101,11 @@ class HttpServer(
|
||||
}
|
||||
|
||||
fun close() {
|
||||
runCatching {
|
||||
serverSocket?.close()
|
||||
coroutineScope.launch {
|
||||
runCatching {
|
||||
serverSocket?.close()
|
||||
socketJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,19 +141,21 @@ class HttpServer(
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
val outputStream = socket.getOutputStream()
|
||||
val writer = PrintWriter(outputStream)
|
||||
val line = reader.readLine() ?: return
|
||||
val line = runCatching { reader.readLine() }.getOrNull() ?: return
|
||||
|
||||
fun close() {
|
||||
runCatching {
|
||||
reader.close()
|
||||
writer.close()
|
||||
outputStream.close()
|
||||
socket.close()
|
||||
}.onFailure {
|
||||
AbstractLogger.directError("failed to close socket", it)
|
||||
}
|
||||
}
|
||||
|
||||
val parse = StringTokenizer(line)
|
||||
if (!parse.hasMoreTokens()) { close(); return }
|
||||
val method = parse.nextToken().uppercase(Locale.getDefault())
|
||||
if (!parse.hasMoreTokens()) { close(); return }
|
||||
var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
|
||||
AbstractLogger.directDebug("[http-server:${port}] $method $fileRequested")
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.6.9
|
||||
APP_VERSION_CODE=325
|
||||
APP_VERSION_NAME=1.7.1
|
||||
APP_VERSION_CODE=327
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
@@ -115,8 +115,8 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://api.xposed.info/") }
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
maven { url = uri("https://api.xposed.info/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user