Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82db6a863 | ||
|
|
9781008b2e | ||
|
|
c71e06095d | ||
|
|
1fdaf6d55d | ||
|
|
8a9258b318 | ||
|
|
6491288513 | ||
|
|
33664c1112 | ||
|
|
bb8e644b4c | ||
|
|
21fba253b1 | ||
|
|
9e5b318120 | ||
|
|
00c5d60b7b | ||
|
|
21cd306132 | ||
|
|
0d42aed0ff | ||
|
|
f8fdd1893f | ||
|
|
a8e2148b26 | ||
|
|
1e9ad8eb2b | ||
|
|
182e7eefeb | ||
|
|
070a8ffaf7 | ||
|
|
95f98221e3 |
@@ -7,6 +7,8 @@ import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.core.app.CoreComponentFactory
|
||||
@@ -30,12 +32,14 @@ import androidx.work.WorkManager
|
||||
import me.eternal.purrfectsnap.bridge.BridgeService
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
|
||||
import me.eternal.purrfectsnap.common.config.ModConfig
|
||||
import me.eternal.purrfectsnap.common.logger.fatalCrash
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.common.util.constantLazyBridge
|
||||
import me.eternal.purrfectsnap.common.util.getPurgeTime
|
||||
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
|
||||
@@ -275,6 +279,67 @@ class RemoteSideContext(
|
||||
androidContext.startActivity(intent)
|
||||
}
|
||||
|
||||
fun requestSocialSnapshotRefresh(
|
||||
openSnapchatFirst: Boolean = true,
|
||||
snapchatWarmupDelayMs: Long = 1200L,
|
||||
returnDelayMs: Long = 1200L
|
||||
) {
|
||||
fun sendSocialSnapshotBroadcast() {
|
||||
runCatching {
|
||||
androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("Failed to request latest social snapshot", it)
|
||||
}
|
||||
}
|
||||
|
||||
if (!openSnapchatFirst) {
|
||||
sendSocialSnapshotBroadcast()
|
||||
return
|
||||
}
|
||||
|
||||
val snapchatIntent = androidContext.packageManager
|
||||
.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
if (snapchatIntent == null) {
|
||||
shortToast(translation["toast_snapchat_not_installed"])
|
||||
sendSocialSnapshotBroadcast()
|
||||
return
|
||||
}
|
||||
|
||||
val returnIntent = Intent(androidContext, MainActivity::class.java).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
)
|
||||
}
|
||||
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
runCatching {
|
||||
androidContext.startActivity(snapchatIntent)
|
||||
mainHandler.postDelayed(
|
||||
{
|
||||
runCatching {
|
||||
androidContext.startActivity(returnIntent)
|
||||
}.onFailure {
|
||||
log.error("Failed to return to PurrfectSnap after Snapchat handoff", it)
|
||||
}
|
||||
mainHandler.postDelayed(
|
||||
{ sendSocialSnapshotBroadcast() },
|
||||
returnDelayMs
|
||||
)
|
||||
},
|
||||
snapchatWarmupDelayMs
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("Failed to launch Snapchat for social snapshot refresh", it)
|
||||
sendSocialSnapshotBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleAnnouncementCheck() {
|
||||
val workManager = WorkManager.getInstance(androidContext)
|
||||
val constraints = Constraints.Builder()
|
||||
|
||||
@@ -170,124 +170,22 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToConfiguredFolder(
|
||||
configuredFolder: String,
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
pendingTask: PendingTask,
|
||||
): GallerySaveResult {
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name)
|
||||
?: folder.createDirectory(name)
|
||||
?: throw Exception("Failed to create output directory $name")
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
outputFileFolder.findFile(fileName)?.let { existingFile ->
|
||||
pendingTask.updateProgress("Comparing existing media")
|
||||
if (existingFile.length() != inputFile.length()) {
|
||||
existingFile.delete()
|
||||
} else {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
?: throw Exception("Failed to open existing media for comparison")
|
||||
|
||||
existingInputStream.use { currentExistingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = currentExistingInputStream.read(buffer2)
|
||||
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
|
||||
stream1.use { s1 ->
|
||||
stream2.use { s2 ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
while (true) {
|
||||
val read1 = s1.read(buffer1)
|
||||
val read2 = s2.read(buffer2)
|
||||
if (read1 != read2) return false
|
||||
if (read1 == -1) return true
|
||||
for (i in 0 until read1) {
|
||||
if (buffer1[i] != buffer2[i]) return false
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
|
||||
?: throw Exception("Failed to create output file $fileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $fileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
|
||||
for (index in 0 until length) {
|
||||
if (left[index] != right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
|
||||
val rawName = outputPath.substringAfterLast("/").ifBlank { "media" }
|
||||
val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName))
|
||||
val currentExtension = rawName.substringAfterLast(".", "").lowercase()
|
||||
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
|
||||
val extension = if (currentExtension == targetExtension) {
|
||||
currentExtension
|
||||
} else {
|
||||
targetExtension
|
||||
}
|
||||
return "$sanitizedBase.$extension"
|
||||
}
|
||||
|
||||
private fun sanitizeFileName(name: String): String {
|
||||
return name
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
.replace(Regex("\\p{Cntrl}"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.ifBlank { "media" }
|
||||
}
|
||||
|
||||
private fun sanitizeRelativePath(path: String): String {
|
||||
return path.split("/")
|
||||
.mapNotNull { segment ->
|
||||
segment.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::sanitizeFileName)
|
||||
}
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun appendNameSuffix(fileName: String, index: Int): String {
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
val baseName = fileName.substringBeforeLast(".", fileName)
|
||||
return if (extension.isBlank()) {
|
||||
"$baseName ($index)"
|
||||
} else {
|
||||
"$baseName ($index).$extension"
|
||||
}
|
||||
}
|
||||
|
||||
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
|
||||
@@ -312,29 +210,103 @@ class DownloadProcessor (
|
||||
return streamsMatch(existingInputStream, inputFile.inputStream())
|
||||
}
|
||||
|
||||
private fun filesMatch(existingFile: File, inputFile: File): Boolean {
|
||||
return streamsMatch(existingFile.inputStream(), inputFile.inputStream())
|
||||
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
|
||||
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
|
||||
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
|
||||
|
||||
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
|
||||
rawName.substringBeforeLast(".")
|
||||
} else {
|
||||
rawName
|
||||
}
|
||||
|
||||
return "${sanitizeFileName(baseName)}.$targetExtension"
|
||||
}
|
||||
|
||||
private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean {
|
||||
existingInputStream.use { currentExistingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
private fun sanitizeFileName(name: String): String {
|
||||
return name
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
.replace(Regex("\\p{Cntrl}"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.ifBlank { "media" }
|
||||
}
|
||||
|
||||
inputInputStream.use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = currentExistingInputStream.read(buffer2)
|
||||
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
|
||||
return false
|
||||
}
|
||||
if (read1 == -1) break
|
||||
private fun sanitizeRelativePath(path: String): String {
|
||||
return path.trimEnd('/').split("/")
|
||||
.mapNotNull { segment ->
|
||||
segment.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::sanitizeFileName)
|
||||
}
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun appendNameSuffix(fileName: String, index: Int): String {
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
val baseName = fileName.substringBeforeLast(".", fileName)
|
||||
return if (extension.isBlank()) {
|
||||
"$baseName ($index)"
|
||||
} else {
|
||||
"$baseName ($index).$extension"
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToConfiguredFolder(
|
||||
configuredFolder: String,
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
pendingTask: PendingTask,
|
||||
): GallerySaveResult {
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name)
|
||||
?: folder.createDirectory(name)
|
||||
?: throw Exception("Failed to create output directory $name")
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
var finalFileName = fileName
|
||||
var collisionCount = 0
|
||||
|
||||
while (true) {
|
||||
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
|
||||
|
||||
if (existingFile.length() == inputFile.length()) {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
collisionCount++
|
||||
finalFileName = appendNameSuffix(fileName, collisionCount)
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
|
||||
?: throw Exception("Failed to create output file $finalFileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $finalFileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
private fun saveToSystemDefault(
|
||||
@@ -346,7 +318,6 @@ class DownloadProcessor (
|
||||
val subPath = sanitizeRelativePath(
|
||||
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
)
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
@@ -356,86 +327,65 @@ class DownloadProcessor (
|
||||
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
|
||||
.joinToString("/") + "/"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val collection = when {
|
||||
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
}
|
||||
val resolver = remoteSideContext.androidContext.contentResolver
|
||||
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
if (extension.isBlank()) baseName else "$baseName.$extension"
|
||||
}
|
||||
findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri ->
|
||||
if (contentMatches(existingUri, inputFile)) {
|
||||
remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName")
|
||||
return GallerySaveResult(existingUri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (attempt in 0..100) {
|
||||
val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt)
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
|
||||
|
||||
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
|
||||
if (contentMatches(existingUri, inputFile)) {
|
||||
return GallerySaveResult(existingUri, alreadyDownloaded = true)
|
||||
}
|
||||
return@let
|
||||
} ?: run {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
val uri = runCatching {
|
||||
resolver.insert(collection, values)
|
||||
}.onFailure {
|
||||
remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}")
|
||||
}.getOrNull() ?: continue
|
||||
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream for $candidateName")
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}.also { resolver.update(uri, it, null, null) }
|
||||
|
||||
return GallerySaveResult(uri)
|
||||
}.onFailure {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it)
|
||||
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) }
|
||||
return GallerySaveResult(uri)
|
||||
}.onFailure {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath")
|
||||
throw IllegalStateException("Failed to allocate unique filename")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
|
||||
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
|
||||
destDir.mkdirs()
|
||||
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
if (extension.isBlank()) baseName else "$baseName.$extension"
|
||||
}
|
||||
var destFile = File(destDir, sanitizedFileName)
|
||||
if (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) {
|
||||
|
||||
var destFile = File(destDir, fileName)
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++))
|
||||
}
|
||||
}
|
||||
FileOutputStream(destFile).use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
|
||||
}
|
||||
|
||||
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(
|
||||
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = Uri.fromFile(destFile)
|
||||
}
|
||||
)
|
||||
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
|
||||
}
|
||||
GallerySaveResult(Uri.fromFile(destFile))
|
||||
return GallerySaveResult(Uri.fromFile(destFile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +504,10 @@ class DownloadProcessor (
|
||||
for (i in 0 until baseUrlNodeList.length) {
|
||||
val baseUrlNode = baseUrlNodeList.item(i)
|
||||
val baseUrl = baseUrlNode.textContent
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
// FIX: Only add prefix if it's not already a full URL
|
||||
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
}
|
||||
}
|
||||
|
||||
val dashOptions = downloadRequest.dashOptions!!
|
||||
|
||||
@@ -93,24 +93,6 @@ fun AppDatabase.replaceMessagingData(
|
||||
executeAsync {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
val friendIds = friends.map { it.userId }.toSet()
|
||||
val groupIds = groups.map { it.conversationId }.toSet()
|
||||
|
||||
getFriends().forEach { friend ->
|
||||
if (friend.userId !in friendIds) {
|
||||
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
|
||||
}
|
||||
}
|
||||
|
||||
getGroups().forEach { group ->
|
||||
if (group.conversationId !in groupIds) {
|
||||
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
friends.forEach { friend ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
|
||||
@@ -46,7 +46,8 @@ data class Task(
|
||||
val type: TaskType,
|
||||
val title: String,
|
||||
val author: String?,
|
||||
val hash: String
|
||||
val hash: String,
|
||||
val isAutoOpen: Boolean = false
|
||||
) {
|
||||
var changeListener: () -> Unit = {}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ class RemoteTaskInterface(
|
||||
private val activeTasks = context.taskManager.getActiveTasks()
|
||||
|
||||
override fun createTask(type: String, title: String, author: String, hash: String): String {
|
||||
val taskType = TaskType.fromKey(type)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(type),
|
||||
type = taskType,
|
||||
title = title,
|
||||
author = author.takeIf { it.isNotBlank() },
|
||||
hash = hash
|
||||
hash = hash,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
context.taskManager.createPendingTask(task)
|
||||
return hash
|
||||
|
||||
@@ -38,11 +38,13 @@ class TaskManager(
|
||||
private val activeTasks = mutableMapOf<Long, PendingTask>()
|
||||
|
||||
private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
|
||||
val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(cursor.getStringOrNull("type")!!),
|
||||
type = taskType,
|
||||
title = cursor.getStringOrNull("title")!!,
|
||||
author = cursor.getStringOrNull("author"),
|
||||
hash = cursor.getStringOrNull("hash")!!
|
||||
hash = cursor.getStringOrNull("hash")!!,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
|
||||
task.extra = cursor.getStringOrNull("extra")
|
||||
|
||||
@@ -71,6 +71,11 @@ import me.eternal.purrfectsnap.ui.util.*
|
||||
import java.io.File
|
||||
|
||||
class TasksRootSection : Routes.Route() {
|
||||
enum class TaskTab {
|
||||
ACTIVE, SCHEDULED
|
||||
}
|
||||
|
||||
internal var selectedTab by mutableStateOf(TaskTab.ACTIVE)
|
||||
internal var activeTasks by mutableStateOf(listOf<PendingTask>())
|
||||
internal var recentTasks = mutableStateListOf<Task>()
|
||||
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
|
||||
@@ -201,15 +206,94 @@ class TasksRootSection : Routes.Route() {
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item(key = "auto_open_card") {
|
||||
var queueItems by remember { mutableStateOf(listOf<Any>()) }
|
||||
var processedCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
runCatching {
|
||||
val autoOpen = context.bridgeService?.messagingBridge?.getAutoOpenInterface()
|
||||
processedCount = autoOpen?.processedCount ?: 0
|
||||
val items = autoOpen?.queueItems ?: emptyList()
|
||||
queueItems = items.mapNotNull {
|
||||
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
|
||||
}
|
||||
}
|
||||
kotlinx.coroutines.delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (queueItems.isNotEmpty() || processedCount > 0) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
IconButton(onClick = {
|
||||
runCatching { context.bridgeService?.messagingBridge?.getAutoOpenInterface()?.reset() }
|
||||
}) {
|
||||
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: ${queueItems.size} \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
|
||||
item {
|
||||
AphelionTasksEmptyState(translation["no_tasks"])
|
||||
}
|
||||
}
|
||||
|
||||
items(activeTasks, key = { it.task.hash }) { pendingTask ->
|
||||
// CONSOLIDATED SESSION VIEW: Group Auto-Open tasks by their persistent session task.
|
||||
// Non-AutoOpen tasks (Downloads, etc.) remain as individual cards.
|
||||
val groupedActiveTasks = activeTasks.distinctBy { it.task.hash }
|
||||
|
||||
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
|
||||
val isAutoOpen = pendingTask.task.isAutoOpen
|
||||
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseAlpha by pulseAnimation.animateFloat(
|
||||
initialValue = 0.15f,
|
||||
targetValue = 0.45f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "alpha"
|
||||
)
|
||||
|
||||
TaskCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (isAutoOpen) {
|
||||
it.border(
|
||||
width = 1.5.dp,
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
|
||||
)
|
||||
),
|
||||
shape = MaterialTheme.shapes.large
|
||||
)
|
||||
} else it
|
||||
},
|
||||
task = pendingTask.task,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
@@ -803,7 +887,13 @@ class TasksRootSection : Routes.Route() {
|
||||
|
||||
if (isActive) {
|
||||
taskProgressLabel?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White)
|
||||
val labelText = if (task.isAutoOpen) {
|
||||
// Live Metrics: Show Snaps/min and session total
|
||||
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 // Placeholder for session start
|
||||
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
|
||||
"$it • $speed snaps/min"
|
||||
} else it
|
||||
Text(labelText, style = MaterialTheme.typography.labelSmall, color = Color.White)
|
||||
}
|
||||
if (taskProgress != -1) {
|
||||
LinearProgressIndicator(
|
||||
@@ -842,4 +932,186 @@ class TasksRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TaskTabSwitcher(
|
||||
selectedTab: TaskTab,
|
||||
onTabSelected: (TaskTab) -> Unit,
|
||||
activeCount: Int,
|
||||
scheduledCount: Int
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
TaskTab.entries.forEach { tab ->
|
||||
val selected = selectedTab == tab
|
||||
val count = if (tab == TaskTab.ACTIVE) activeCount else scheduledCount
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (selected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f),
|
||||
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.12f)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onTabSelected(tab) }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (tab == TaskTab.ACTIVE) Icons.Filled.Timer else Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (tab == TaskTab.ACTIVE) (context.translation["tasks_tab_active"] ?: "Active") else (context.translation["tasks_tab_scheduled"] ?: "Scheduled"),
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TasksHeader(
|
||||
selectedTab: TaskTab,
|
||||
onTabSelected: (TaskTab) -> Unit,
|
||||
activeCount: Int,
|
||||
scheduledCount: Int,
|
||||
runningCount: Int,
|
||||
subtitle: String,
|
||||
onClear: () -> Unit,
|
||||
onMerge: () -> Unit,
|
||||
canMerge: Boolean
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.tasks"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (canMerge) {
|
||||
Surface(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMerge()
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = context.translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(context.translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlaylistAddCheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = (context.translation["tasks_running_count"] ?: "{count} running")
|
||||
.replace("{count}", runningCount.toString()),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onClear) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = context.translation["tasks_clear_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TaskTabSwitcher(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = onTabSelected,
|
||||
activeCount = activeCount,
|
||||
scheduledCount = scheduledCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -43,8 +46,10 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -72,6 +77,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
@@ -80,6 +86,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.DialogProperties
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -162,6 +169,28 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isRandomizedProfileEnabled(): Boolean {
|
||||
return context.config.root.experimental.spoof.randomizeDeviceProfile.globalState == true
|
||||
}
|
||||
|
||||
internal fun requestFreshRandomizedProfile() {
|
||||
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
|
||||
randomizeConfig.profileGenerationToken.set(UUID.randomUUID().toString())
|
||||
randomizeConfig.currentProfileSnapshot.set("")
|
||||
}
|
||||
|
||||
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"]
|
||||
?: "No generated profile is available yet. Enable the feature in Snapchat first.")
|
||||
}
|
||||
|
||||
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
|
||||
return propertyName == "generate_fresh_profile_action" || propertyName == "view_current_profile_action"
|
||||
}
|
||||
|
||||
fun navigateToMainRoot() {
|
||||
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
|
||||
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
|
||||
@@ -330,9 +359,17 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) {
|
||||
internal fun PropertyAction(
|
||||
property: PropertyPair<*>,
|
||||
onConfigChanged: () -> Unit,
|
||||
registerClickCallback: (() -> Unit) -> (() -> Unit)
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
|
||||
var showRandomProfileProgressDialog by remember { mutableStateOf(false) }
|
||||
var randomProfileStatus by remember { mutableStateOf("") }
|
||||
var showCurrentRandomProfileDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
|
||||
|
||||
@@ -348,7 +385,64 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
val propertyValue = property.value
|
||||
fun persistConfig() = context.config.writeConfig()
|
||||
val randomProfileEnabled = isRandomizedProfileEnabled()
|
||||
val isRandomizedProfileContainer = property.name == "randomize_device_profile"
|
||||
fun persistConfig() {
|
||||
context.config.writeConfig()
|
||||
onConfigChanged()
|
||||
}
|
||||
|
||||
if (showRandomProfileProgressDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = context.translation["manager.dialogs.randomize_device_profile.title"]
|
||||
?: "Generating random device profile",
|
||||
text = randomProfileStatus,
|
||||
icon = Icons.Filled.AutoAwesome,
|
||||
confirmButtonText = "",
|
||||
onConfirm = {},
|
||||
loading = true,
|
||||
showIcon = false,
|
||||
showCloseButton = false,
|
||||
confirmEnabled = false
|
||||
)
|
||||
}
|
||||
|
||||
if (showCurrentRandomProfileDialog) {
|
||||
val profileSnapshot = getRandomizedProfileSnapshot()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showCurrentRandomProfileDialog = false },
|
||||
title = context.translation["manager.dialogs.randomize_device_profile.view_title"]
|
||||
?: "Current randomized profile",
|
||||
text = "",
|
||||
icon = Icons.Filled.Visibility,
|
||||
dismissButtonText = context.translation["button.copy"] ?: "Copy",
|
||||
onDismiss = {
|
||||
clipboardManager.setText(AnnotatedString(profileSnapshot))
|
||||
context.shortToast(
|
||||
context.translation["manager.dialogs.randomize_device_profile.copied"]
|
||||
?: "Randomized profile copied"
|
||||
)
|
||||
},
|
||||
confirmButtonText = context.translation["button.positive"],
|
||||
onConfirm = { showCurrentRandomProfileDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = profileSnapshot,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
|
||||
registerDialogOnClickCallback()
|
||||
@@ -518,12 +612,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = state,
|
||||
onCheckedChange = {
|
||||
onCheckedChange = { requestedState ->
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
state = state.not()
|
||||
propertyValue.setAny(state)
|
||||
state = requestedState
|
||||
propertyValue.setAny(requestedState)
|
||||
persistConfig()
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
@@ -566,6 +660,45 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
|
||||
if (dataType == DataProcessors.Type.STRING && isRandomizedProfileActionProperty(property.name)) {
|
||||
val actionLabel = when (property.name) {
|
||||
"generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile"
|
||||
"view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile"
|
||||
else -> property.name
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (property.name == "generate_fresh_profile_action") {
|
||||
showRandomProfileProgressDialog = true
|
||||
coroutineScope.launch {
|
||||
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
|
||||
?: "Allocating a randomized device fingerprint"
|
||||
delay(260)
|
||||
requestFreshRandomizedProfile()
|
||||
persistConfig()
|
||||
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.finalizing"]
|
||||
?: "Finalizing the all-in-one profile and disabling manual overrides"
|
||||
delay(260)
|
||||
showRandomProfileProgressDialog = false
|
||||
context.shortToast(
|
||||
context.translation["manager.dialogs.randomize_device_profile.refresh_requested"]
|
||||
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showCurrentRandomProfileDialog = true
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(actionLabel, maxLines = 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dialogComposable = {
|
||||
when (dataType) {
|
||||
DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
|
||||
@@ -573,8 +706,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
|
||||
val isMessageListProperty = property.key.name.endsWith("_messages")
|
||||
val isSleepWindowProperty = property.key.name.contains("sleep_window")
|
||||
|
||||
if (isMessageListProperty) {
|
||||
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
|
||||
} else if (isSleepWindowProperty) {
|
||||
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
|
||||
} else {
|
||||
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
|
||||
}
|
||||
@@ -660,12 +797,35 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = state,
|
||||
onCheckedChange = {
|
||||
onCheckedChange = { requestedState ->
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
state = state.not()
|
||||
container.globalState = state
|
||||
if (isRandomizedProfileContainer && requestedState) {
|
||||
showRandomProfileProgressDialog = true
|
||||
coroutineScope.launch {
|
||||
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
|
||||
?: "Allocating a randomized device fingerprint"
|
||||
delay(260)
|
||||
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.network"]
|
||||
?: "Preparing network, locale, and telephony values"
|
||||
delay(260)
|
||||
container.globalState = true
|
||||
state = true
|
||||
persistConfig()
|
||||
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.done"]
|
||||
?: "Randomized device profile generated"
|
||||
delay(220)
|
||||
showRandomProfileProgressDialog = false
|
||||
context.log.info("Enabled randomized device profile mode from manager UI")
|
||||
}
|
||||
return@Switch
|
||||
}
|
||||
state = requestedState
|
||||
container.globalState = requestedState
|
||||
if (!requestedState && isRandomizedProfileContainer) {
|
||||
context.log.info("Disabled randomized device profile mode from manager UI")
|
||||
}
|
||||
persistConfig()
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
@@ -722,7 +882,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
|
||||
internal fun PropertyCard(
|
||||
property: PropertyPair<*>,
|
||||
configRefreshNonce: Int,
|
||||
onConfigChanged: () -> Unit,
|
||||
onOpen: (() -> Unit)? = null
|
||||
) {
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val noticeColorMap = remember {
|
||||
@@ -736,6 +901,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val versionCheck = remember { property.key.params.versionCheck }
|
||||
val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)}
|
||||
val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true }
|
||||
val isInteractionEnabled = !isComponentDisabled
|
||||
|
||||
val cardShape = RoundedCornerShape(22.dp)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
@@ -753,8 +919,9 @@ class FeaturesRootSection : Routes.Route() {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp)
|
||||
.graphicsLayer { if (isComponentDisabled) alpha = 0.5f }
|
||||
.graphicsLayer { if (!isInteractionEnabled) alpha = 0.5f }
|
||||
.clickable(
|
||||
enabled = isInteractionEnabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
@@ -852,7 +1019,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
PropertyAction(property, registerClickCallback = { callback ->
|
||||
PropertyAction(property, onConfigChanged = onConfigChanged, registerClickCallback = { callback ->
|
||||
if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
|
||||
clickCallback = {
|
||||
routes.manageRuleFeature.navigate {
|
||||
@@ -1406,6 +1573,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
var configRefreshNonce by rememberSaveable { mutableStateOf(0) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -1479,7 +1647,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
upsertHistory(liveSearchQuery, sharedSearchHistory)
|
||||
}
|
||||
} else null
|
||||
PropertyCard(item, onOpen = onOpen)
|
||||
PropertyCard(
|
||||
property = item,
|
||||
configRefreshNonce = configRefreshNonce,
|
||||
onConfigChanged = { configRefreshNonce++ },
|
||||
onOpen = onOpen
|
||||
)
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(12.dp)) }
|
||||
@@ -1634,9 +1807,14 @@ class FeaturesRootSection : Routes.Route() {
|
||||
onBack: (() -> Unit)? = null,
|
||||
) {
|
||||
PropertiesView(
|
||||
properties = remember {
|
||||
properties = remember(configContainer.globalState) {
|
||||
configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair<Any> }.filter {
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
|
||||
!it.key.params.flags.contains(ConfigFlag.HIDDEN) &&
|
||||
(
|
||||
configContainer !== context.config.root.experimental.spoof.randomizeDeviceProfile ||
|
||||
configContainer.globalState == true ||
|
||||
!isRandomizedProfileActionProperty(it.key.name)
|
||||
)
|
||||
}
|
||||
},
|
||||
stateKey = stateKey,
|
||||
|
||||
@@ -126,7 +126,7 @@ class BetterLocationRoot : Routes.Route() {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = context.translation.format(
|
||||
text = translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to friendLocation.latitude.toFloat().toString(),
|
||||
"longitude" to friendLocation.longitude.toFloat().toString()
|
||||
|
||||
@@ -24,11 +24,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
import me.eternal.purrfectsnap.storage.getGroups
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
@@ -219,39 +219,70 @@ class AddFriendDialog(
|
||||
var hasFetchError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
|
||||
coroutineScope.launch {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else friends
|
||||
fun applySnapshot(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
|
||||
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
|
||||
coroutineScope.launch {
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
applySnapshot(
|
||||
context.database.getFriends(descOrder = true),
|
||||
context.database.getGroups()
|
||||
)
|
||||
}
|
||||
|
||||
if (context.bridgeService != null) {
|
||||
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
|
||||
} else {
|
||||
context.database.receiveMessagingDataCallback = updateSnapshot
|
||||
}
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to send broadcast", it)
|
||||
hasFetchError = true
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeoutJob = coroutineScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(20000)
|
||||
hasFetchError = true
|
||||
delay(25000)
|
||||
if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) {
|
||||
hasFetchError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,11 @@ import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
@@ -63,13 +61,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
internal fun requestLatestSnapshot() {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to request latest social snapshot", it)
|
||||
}
|
||||
context.requestSocialSnapshotRefresh()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -466,6 +466,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
var changelogLoading by remember { mutableStateOf(false) }
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
@@ -531,6 +535,31 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog() {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
body.trim()
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogText = text
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogError = e.message ?: "Failed to fetch"
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
|
||||
@@ -626,7 +655,8 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
@@ -634,6 +664,12 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
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(
|
||||
@@ -778,6 +814,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
text = "", icon = Icons.Filled.Notifications,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -796,6 +833,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -806,6 +844,28 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
if (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
|
||||
@@ -314,6 +314,7 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,17 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
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() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
|
||||
@@ -58,7 +58,9 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
|
||||
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -117,7 +119,6 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
// The "Structured Glass" Container (Dynamically Morphed)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -129,32 +130,226 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = controlsHeight - 44.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
|
||||
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
|
||||
Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TaskTab.entries.forEach { tab ->
|
||||
val isSelected = selectedTab == tab
|
||||
val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(38.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.White.copy(alpha = backgroundAlpha))
|
||||
.clickable {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
selectedTab = tab
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"),
|
||||
color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(activeTasks, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
|
||||
val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND }
|
||||
val recentList = recentTasks.filter { task ->
|
||||
task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash }
|
||||
}
|
||||
items(recentTasks, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
|
||||
val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND }
|
||||
val scheduledRecent = recentTasks.filter { task ->
|
||||
task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash }
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = 8.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (selectedTab == TaskTab.ACTIVE) {
|
||||
item(key = "auto_open_card") {
|
||||
var queueItems by remember { mutableStateOf(listOf<Any>()) }
|
||||
var processedCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
runCatching {
|
||||
val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface
|
||||
processedCount = autoOpen?.processedCount ?: 0
|
||||
val items = autoOpen?.queueItems ?: emptyList()
|
||||
queueItems = items.mapNotNull {
|
||||
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
|
||||
}
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
val queueSize = queueItems.size
|
||||
|
||||
if (queueSize > 0 || processedCount > 0) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
null,
|
||||
tint = Color.White.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) {
|
||||
Text(
|
||||
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
queueItems.forEach { rawItem ->
|
||||
val item = rawItem as? Map<String, String> ?: return@forEach
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
|
||||
Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f))
|
||||
}
|
||||
Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0 || queueSize > 0) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(36.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f))
|
||||
) {
|
||||
Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeList.isEmpty() && recentList.isEmpty()) {
|
||||
item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") }
|
||||
}
|
||||
|
||||
val groupedActiveTasks = activeList.distinctBy { it.task.hash }
|
||||
|
||||
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
|
||||
val isAutoOpenTask = pendingTask.task.isAutoOpen
|
||||
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseAlpha by pulseAnimation.animateFloat(
|
||||
initialValue = 0.15f,
|
||||
targetValue = 0.45f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "alpha"
|
||||
)
|
||||
|
||||
AphelionTaskCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (isAutoOpenTask) {
|
||||
it.border(
|
||||
width = 1.5.dp,
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
} else it
|
||||
},
|
||||
task = pendingTask.task,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
}
|
||||
|
||||
items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
} else {
|
||||
if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) {
|
||||
item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") }
|
||||
}
|
||||
|
||||
items(scheduledActive, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(scheduledRecent, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,6 +362,35 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (taskSelection.size > 1) {
|
||||
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||
}
|
||||
|
||||
if (canMergeSelection) {
|
||||
Surface(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
mergeSelection(taskSelection.toList().also {
|
||||
taskSelection.clear()
|
||||
}.map { it.first to it.second!! })
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
@@ -191,34 +415,12 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
|
||||
Surface(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
mergeSelection(
|
||||
taskSelection.toList().also { taskSelection.clear() }
|
||||
.map { it.first to it.second!! }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -227,11 +429,11 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
if (showConfirmDialog) {
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
} else {
|
||||
translation["remove_all_tasks_confirm"]
|
||||
translation["tasks_remove_all_tasks_confirm"]
|
||||
}
|
||||
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
|
||||
val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"]
|
||||
|
||||
TaskDangerDialog(
|
||||
visible = showConfirmDialog,
|
||||
@@ -494,10 +696,19 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
|
||||
}
|
||||
|
||||
if (!taskStatus.isFinalStage()) {
|
||||
if (!isActive) {
|
||||
if (isActive) {
|
||||
taskProgressLabel?.let {
|
||||
val labelText = if (task.isAutoOpen) {
|
||||
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0
|
||||
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
|
||||
"$it • $speed snaps/min"
|
||||
} else it
|
||||
Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White)
|
||||
}
|
||||
} else {
|
||||
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
|
||||
}
|
||||
if (taskProgress != -1 && taskProgressLabel == null) {
|
||||
if (taskProgress != -1 && (taskProgressLabel == null || isActive)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { taskProgress.toFloat() / 100f },
|
||||
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -345,6 +345,10 @@ object LegacyTheme : ThemeContract {
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogText by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showFullChangelogDialog by remember { mutableStateOf(false) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError by remember { mutableStateOf<String?>(null) }
|
||||
var fullChangelogText by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by remember { mutableStateOf(false) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
var announcementsError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -415,6 +419,23 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog(url: String) {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true; fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
|
||||
response.body?.string()?.trim() ?: throw IllegalStateException("Empty changelog body")
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) { fullChangelogText = text; fullChangelogLoading = false }
|
||||
}.onFailure { error ->
|
||||
withContext(Dispatchers.Main) { fullChangelogError = error.message ?: "Failed to load changelog"; fullChangelogLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
|
||||
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
|
||||
@@ -450,6 +471,9 @@ object LegacyTheme : ThemeContract {
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) {
|
||||
showAnnouncementsDialog = true; loadAnnouncements()
|
||||
}
|
||||
LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") {
|
||||
showFullChangelogDialog = true; loadFullChangelog(changelogUrl)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
LocalHomeActionChips()
|
||||
@@ -570,6 +594,7 @@ object LegacyTheme : ThemeContract {
|
||||
onConfirm = { showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -587,6 +612,7 @@ object LegacyTheme : ThemeContract {
|
||||
text = "", icon = Icons.Filled.Notifications,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
@@ -597,6 +623,24 @@ object LegacyTheme : ThemeContract {
|
||||
)
|
||||
}
|
||||
|
||||
if (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "", icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
@@ -877,6 +921,7 @@ object LegacyTheme : ThemeContract {
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,21 +11,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -34,15 +27,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
@@ -55,16 +44,7 @@ class MappingsScreen : SetupScreen() {
|
||||
val translation = context.translation
|
||||
var infoText by remember { mutableStateOf(null as String?) }
|
||||
var isGenerating by remember { mutableStateOf(false) }
|
||||
var showCompletionNotice by remember { mutableStateOf(false) }
|
||||
var completionCountdown by remember { mutableIntStateOf(10) }
|
||||
|
||||
fun finishMappings() {
|
||||
if (isFirstRunFlow) {
|
||||
showCompletionNotice = true
|
||||
} else {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
fun finishMappings() = goNext()
|
||||
|
||||
if (infoText != null) {
|
||||
fun dismiss() {
|
||||
@@ -87,103 +67,6 @@ class MappingsScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showCompletionNotice) {
|
||||
if (showCompletionNotice) {
|
||||
completionCountdown = 10
|
||||
while (completionCountdown > 0) {
|
||||
delay(1000)
|
||||
completionCountdown--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompletionNotice) {
|
||||
val confirmLabel = if (completionCountdown > 0) {
|
||||
translation.format(
|
||||
"setup.mappings.confirm_understand_timeout",
|
||||
"seconds" to completionCountdown.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.mappings.confirm_understand"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
title = translation["setup.mappings.notice_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
confirmEnabled = completionCountdown == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_intro"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_1"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_2"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_3"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_rooted_title"],
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_rooted_body"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (isGenerating) return@launch
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -26,9 +28,12 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -56,6 +61,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
@@ -63,6 +72,7 @@ import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.File
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
@@ -72,6 +82,146 @@ import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
|
||||
class AlertDialogs(
|
||||
private val translation: LocaleWrapper,
|
||||
){
|
||||
@Composable
|
||||
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
|
||||
val currentValue = property.value.getNullable()?.toString() ?: "[]"
|
||||
val propertyName = translation[property.key.propertyName()]
|
||||
|
||||
MessageListManagerDialog(
|
||||
title = propertyName ?: "",
|
||||
messageListJson = currentValue,
|
||||
onSave = { newValue: String ->
|
||||
property.value.setAny(newValue)
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AutoOpenScheduleDialog(
|
||||
property: PropertyPair<String>,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val windowParts = (property.value.get() as String).split("-")
|
||||
val startTime = windowParts.getOrNull(0)?.split(":") ?: listOf("23", "00")
|
||||
val endTime = windowParts.getOrNull(1)?.split(":") ?: listOf("07", "00")
|
||||
|
||||
var isEditingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val startState = rememberTimePickerState(
|
||||
initialHour = startTime.getOrNull(0)?.toIntOrNull() ?: 23,
|
||||
initialMinute = startTime.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
is24Hour = true
|
||||
)
|
||||
val endState = rememberTimePickerState(
|
||||
initialHour = endTime.getOrNull(0)?.toIntOrNull() ?: 7,
|
||||
initialMinute = endTime.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
is24Hour = true
|
||||
)
|
||||
|
||||
DefaultDialogCard {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["auto_open_snaps.auto_open_schedule.title"] ?: "Auto Open Scheduler",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val activeColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
|
||||
val inactiveColor = Color.Transparent
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (!isEditingEnd) activeColor else inactiveColor)
|
||||
.clickable { isEditingEnd = false }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${translation["auto_open_snaps.auto_open_schedule.start"] ?: "Start"}: ${String.format("%02d:%02d", startState.hour, startState.minute)}",
|
||||
color = if (!isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
fontWeight = if (!isEditingEnd) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isEditingEnd) activeColor else inactiveColor)
|
||||
.clickable { isEditingEnd = true }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${translation["auto_open_snaps.auto_open_schedule.end"] ?: "End"}: ${String.format("%02d:%02d", endState.hour, endState.minute)}",
|
||||
color = if (isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
fontWeight = if (isEditingEnd) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TimePicker(
|
||||
state = if (isEditingEnd) endState else startState,
|
||||
colors = TimePickerDefaults.colors(
|
||||
clockDialColor = Color.White.copy(alpha = 0.05f),
|
||||
clockDialSelectedContentColor = Color.White,
|
||||
clockDialUnselectedContentColor = Color.White.copy(alpha = 0.7f),
|
||||
selectorColor = PurrfectPalette.glowPrimary,
|
||||
periodSelectorBorderColor = PurrfectPalette.glowPrimary,
|
||||
periodSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
periodSelectorUnselectedContainerColor = Color.Transparent,
|
||||
periodSelectorSelectedContentColor = Color.White,
|
||||
periodSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f),
|
||||
timeSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
timeSelectorUnselectedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
timeSelectorSelectedContentColor = Color.White,
|
||||
timeSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = translation["button.negative"], color = Color.White)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val startStr = String.format("%02d:%02d", startState.hour, startState.minute)
|
||||
val endStr = String.format("%02d:%02d", endState.hour, endState.minute)
|
||||
property.value.setAny("$startStr-$endStr")
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.positive"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -606,6 +756,13 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
fun dismissKeyboard() {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
mapView.value = remember {
|
||||
Configuration.getInstance().apply {
|
||||
@@ -642,12 +799,37 @@ class AlertDialogs(
|
||||
|
||||
overlays.add(object: Overlay() {
|
||||
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
|
||||
mapView.invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
addMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(marker.value)
|
||||
}
|
||||
}
|
||||
@@ -702,6 +884,19 @@ class AlertDialogs(
|
||||
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
val resultsScrollState = rememberScrollState()
|
||||
|
||||
BackHandler {
|
||||
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
|
||||
if (shouldDismissKeyboard) {
|
||||
dismissKeyboard()
|
||||
locationName = ""
|
||||
addressResults = emptyList()
|
||||
searchJob?.cancel()
|
||||
searchJob = null
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search() {
|
||||
if (locationSearchProvider == "google_maps") {
|
||||
// Google Maps Search
|
||||
@@ -946,6 +1141,7 @@ class AlertDialogs(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
|
||||
mapView.value?.controller?.setCenter(marker.value?.position)
|
||||
mapView.value?.invalidate()
|
||||
@@ -1206,23 +1402,7 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
|
||||
val currentValue = property.value.getNullable()?.toString() ?: "[]"
|
||||
val propertyName = translation[property.key.propertyName()]
|
||||
|
||||
MessageListManagerDialog(
|
||||
title = propertyName,
|
||||
messageListJson = currentValue,
|
||||
onSave = { newValue ->
|
||||
property.value.setAny(newValue)
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageListManagerDialog(
|
||||
@@ -1259,7 +1439,6 @@ class AlertDialogs(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Message list
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1275,7 +1454,7 @@ class AlertDialogs(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = translation["auto_reply_messages.dialog.no_messages"],
|
||||
text = translation["bulk_messaging_action.no_messages_found"] ?: "No messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -1317,24 +1496,14 @@ class AlertDialogs(
|
||||
showAddDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["common.edit"] ?: "Edit", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
messageList = messageList.toMutableList().apply {
|
||||
removeAt(index)
|
||||
}
|
||||
messageList = messageList.toMutableList().apply { removeAt(index) }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Icon(Icons.Default.Delete, contentDescription = translation["common.delete"] ?: "Delete", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1344,7 +1513,6 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
// Add button
|
||||
Button(
|
||||
onClick = {
|
||||
editingIndex = -1
|
||||
@@ -1354,20 +1522,13 @@ class AlertDialogs(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["auto_reply_messages.dialog.add_message"])
|
||||
Text(text = translation["common.add"] ?: "Add Message")
|
||||
}
|
||||
|
||||
// Dialog buttons
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1376,17 +1537,14 @@ class AlertDialogs(
|
||||
) {
|
||||
Button(
|
||||
onClick = { onDismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val gson = com.google.gson.Gson()
|
||||
val jsonString = gson.toJson(messageList)
|
||||
onSave(jsonString)
|
||||
onSave(gson.toJson(messageList))
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
@@ -1396,7 +1554,6 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Edit message dialog
|
||||
if (showAddDialog) {
|
||||
StandardDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
@@ -1406,7 +1563,7 @@ class AlertDialogs(
|
||||
) {
|
||||
DefaultDialogCard {
|
||||
Text(
|
||||
text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["auto_reply_messages.dialog.edit_message"],
|
||||
text = if (editingIndex == -1) translation["common.add"] ?: "Add Message" else translation["common.edit"] ?: "Edit Message",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
@@ -1418,13 +1575,13 @@ class AlertDialogs(
|
||||
TextField(
|
||||
value = editingText,
|
||||
onValueChange = { editingText = it },
|
||||
label = { Text(translation["auto_reply_messages.dialog.message_label"]) },
|
||||
label = { Text(translation["common.message"] ?: "Message") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
placeholder = { Text(translation["auto_reply_messages.dialog.message_placeholder"]) }
|
||||
placeholder = { Text(translation["common.type_message"] ?: "Type message...") }
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -1435,32 +1592,22 @@ class AlertDialogs(
|
||||
) {
|
||||
Button(
|
||||
onClick = { showAddDialog = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (editingText.isNotBlank()) {
|
||||
if (editingIndex == -1) {
|
||||
// Add new message
|
||||
messageList = messageList.toMutableList().apply {
|
||||
add(editingText)
|
||||
}
|
||||
} else {
|
||||
// Edit existing message
|
||||
messageList = messageList.toMutableList().apply {
|
||||
set(editingIndex, editingText)
|
||||
}
|
||||
messageList = messageList.toMutableList().apply {
|
||||
if (editingIndex == -1) add(editingText) else set(editingIndex, editingText)
|
||||
}
|
||||
}
|
||||
showAddDialog = false
|
||||
},
|
||||
enabled = editingText.isNotBlank()
|
||||
) {
|
||||
Text(text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["button.save"])
|
||||
Text(text = if (editingIndex == -1) translation["common.add"] ?: "Add" else translation["button.save"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1468,3 +1615,4 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import android.provider.Settings
|
||||
import android.view.*
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -47,6 +50,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import java.util.UUID
|
||||
@@ -197,6 +202,21 @@ private fun InlineDialog(
|
||||
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
|
||||
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val fallbackBackDispatcherOwner = remember(onDismissRequest) {
|
||||
object : OnBackPressedDispatcherOwner {
|
||||
private val lifecycleRegistry = LifecycleRegistry(this).apply {
|
||||
currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
private val dispatcher = OnBackPressedDispatcher(onDismissRequest)
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = lifecycleRegistry
|
||||
|
||||
override val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||
get() = dispatcher
|
||||
}
|
||||
}
|
||||
val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -212,43 +232,45 @@ private fun InlineDialog(
|
||||
),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(screenWidthDp)
|
||||
.height(screenHeightDp)
|
||||
.then(
|
||||
if (dismissOnClickOutside) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onDismissRequest
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.semantics { dialog() },
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
|
||||
targetScale = 0.96f,
|
||||
animationSpec = tween(120)
|
||||
)
|
||||
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(screenWidthDp)
|
||||
.height(screenHeightDp)
|
||||
.then(
|
||||
if (dismissOnClickOutside) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onDismissRequest
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.semantics { dialog() },
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
|
||||
targetScale = 0.96f,
|
||||
animationSpec = tween(120)
|
||||
)
|
||||
) {
|
||||
content()
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.5.3").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("298").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.2").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("314").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,3 +1,148 @@
|
||||
## v1.6.2
|
||||
- New: Randomized Device Profile Feature!
|
||||
- Show Activation Overlay
|
||||
- Build Properties
|
||||
- Device Identity
|
||||
- Manufacturer + Model
|
||||
- Brand + Product
|
||||
- Hardware + Board
|
||||
- ABI Lists
|
||||
- Combined ABIs
|
||||
- Split ABIs
|
||||
- System Properties
|
||||
- Build
|
||||
- Locale
|
||||
- Telephony
|
||||
- Build Version
|
||||
- Fingerprint
|
||||
- Display
|
||||
- Host
|
||||
- Bootloader
|
||||
- Build Time
|
||||
- Locale Options
|
||||
- Locale
|
||||
- Language
|
||||
- Region
|
||||
- Time
|
||||
- Time Zone ID
|
||||
- Time Zone Display Name
|
||||
- Auto Time
|
||||
- Auto Time Zone
|
||||
- Telephony Options
|
||||
- MMS
|
||||
- User Agent
|
||||
- Network Identity
|
||||
- Network Type
|
||||
- Operator Numeric
|
||||
- Operator Name
|
||||
- Country ISO
|
||||
- SIM Identity
|
||||
- Country ISO
|
||||
- Operator Numeric
|
||||
- Operator Name
|
||||
- SIM State
|
||||
- Has ICC Card
|
||||
- Phone Capabilities
|
||||
- Phone Count
|
||||
- Hearing Aid Support
|
||||
- TTY Support
|
||||
- World Phone
|
||||
- Roaming
|
||||
- SMS + Voice Capability
|
||||
- Phone Type
|
||||
- Settings Options
|
||||
- Secure Settings
|
||||
- Base
|
||||
- TTS
|
||||
- System Settings
|
||||
- Base
|
||||
- Bluetooth
|
||||
- Global Settings
|
||||
- Base
|
||||
- Network Options
|
||||
- Wi-Fi
|
||||
- SSID
|
||||
- RSSI
|
||||
- DNS
|
||||
- Servers
|
||||
- Search Domains
|
||||
- Private DNS
|
||||
- Captive Portal
|
||||
- Capability
|
||||
- Identifier Options
|
||||
- Android ID
|
||||
- String Value
|
||||
- Long Value
|
||||
- Advertising ID
|
||||
- Settings Value
|
||||
- Play Services
|
||||
- Hardware Addresses
|
||||
- Wi-Fi MAC
|
||||
- Bluetooth MAC
|
||||
- Persistent App Language
|
||||
- Randomize IP Address
|
||||
- Generate Fresh Profile
|
||||
- View Current Profile
|
||||
- New: Spoof Viewing Gallery Presence
|
||||
- New: Spoof Reply Camera Presence
|
||||
- New: I Can See you 2 Friend Tracker Rule
|
||||
- New: I Can See you 3 Friend Tracker Rule
|
||||
- New: Disable "Can't Login?" overlay feature
|
||||
- Fix: Social Tab showing Failed to fetch Data for some accounts
|
||||
- Fix: Pick a Location Dialog Crash
|
||||
- Fix: Auto Open Notification duplication when snapchat is killed or force closed(tq to Kaladin)
|
||||
- Fix: Fixed a logic error where AutoOpenSnaps would ignore per-conversation rules(tq to Kaladin)
|
||||
- Fix: Auto Open engine stuck in "Monitoring" even when there are snaps in the queue(tq to Kaladin)
|
||||
- Fix: Auto Open session toggles not working as expected(tq to Kaladin)
|
||||
- Fix: Auto Open engine is now optimmized to work with the lower end devices to reduce the overheating and excessive lag(tq to Kaladin)
|
||||
- New: Auto Open Scheduler, a new toggle and a new clock picker to schedule time period for your auto open engine to process(tq to Kaladin)
|
||||
- New: Auto Open Notification session statistics toggles, now you can choose which stats to show in the notification(tq to Kaladin)
|
||||
- New: Updated Auto Open notification stats which now includes a tiered "Processing Speed" and "Estimated Finish" stats(tq to Kaladin)
|
||||
- Fix: Video Downloader is saving the video files from spotlight and stories with extension as ".dat file"(tq to Kaladin)
|
||||
|
||||
## v1.6.1
|
||||
- Fix: Custom Frame Rate
|
||||
- Fix: Conversation Sound Style
|
||||
- Fix: Auto Reactions when downloading stories(Removed story thumbnail feature)
|
||||
|
||||
## v1.6.0
|
||||
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
|
||||
- New: Call Metadata Notifier!
|
||||
- New: Pin Messages!(Locally)
|
||||
- Fix: Select highest quality media by comparing resolution(tq to Issac XT)
|
||||
|
||||
## v1.5.9
|
||||
- New: Block Calls Feature!
|
||||
- New: Unlock Zoom Limit Feature!
|
||||
|
||||
## v1.5.8
|
||||
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
|
||||
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)
|
||||
- Fix: Optimized 1MB socket buffers for high-speed throughput which should upload the video/snap faster then before(tq to Kaladin)
|
||||
- New: Aphelion Task page went full layout overhaul with segmented "Active" and "Scheduled" tab views(tq to Kaladin)
|
||||
- FixL Auto-Open Status Card: Restored real-time visibility of processed snaps and queue status(tq to Kaladin)
|
||||
- New: Smart trickle which has background processing with resource awareness (Paused/Trickle/Normal states)(tq to Kaladin)
|
||||
- New: Four different states for auto open processing so that the device can be usable with auto open in the background(tq to Kaladin)
|
||||
- Fix: memory hogging of the auto open queues, now the device should run much faster without any lag or stuttures(tq to Kaladin)
|
||||
- Fix: "dot in username issue" with the implementation of unique incrementing ((1).jpg), where previously it was overwriting the previous file(tq to Kaladin)
|
||||
- Fix: Lock Indicator for the snaps, which should now accurately be working on all versions(tq to Kaladin)
|
||||
- Fix: Pressing back while keyboard is open in spoofed coordinates would close the dialog
|
||||
- Fix: Not able to scroll in the "Can't Login?" dialog
|
||||
|
||||
## v1.5.6
|
||||
- New: Add "Can't login" hint directly in the login screen
|
||||
|
||||
## v1.5.5
|
||||
- Fix: Custom Emoji now works for all devices!
|
||||
- New: Story preview in story batch download dialog
|
||||
- Fix: Auto Skip Stories getting stuck
|
||||
- Fix: Stories ending up saving in .dat format
|
||||
|
||||
## v1.5.4
|
||||
- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap
|
||||
- Fix: Spoof Coordinates Title
|
||||
- New: Changelogs feature
|
||||
|
||||
## v1.5.3
|
||||
- New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue)
|
||||
- Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.eternal.purrfectsnap.bridge;
|
||||
|
||||
interface AutoOpenInterface {
|
||||
int getProcessedCount();
|
||||
List<String> getQueueItems(); // returns JSON serialized SnapQueueItem list
|
||||
void reset();
|
||||
}
|
||||
@@ -20,4 +20,6 @@ interface MessagingBridge {
|
||||
@nullable String updateMessage(String conversationId, long clientMessageId, String messageUpdate);
|
||||
|
||||
@nullable String getOneToOneConversationId(String userId);
|
||||
|
||||
me.eternal.purrfectsnap.bridge.AutoOpenInterface getAutoOpenInterface();
|
||||
}
|
||||
@@ -532,6 +532,18 @@
|
||||
"title": "Export Sensitive Data?",
|
||||
"content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)"
|
||||
},
|
||||
"randomize_device_profile": {
|
||||
"title": "Generating random device profile",
|
||||
"done": "Randomized device profile generated",
|
||||
"view_title": "Current randomized profile",
|
||||
"empty": "No generated profile is available yet. Enable the feature in Snapchat first.",
|
||||
"refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.",
|
||||
"phase": {
|
||||
"allocating": "Allocating a randomized device fingerprint",
|
||||
"network": "Preparing network, locale, and telephony values",
|
||||
"finalizing": "Finalizing the all-in-one profile and disabling manual overrides"
|
||||
}
|
||||
},
|
||||
"messaging_action": {
|
||||
"title": "Choose content types to process",
|
||||
"select_all_button": "Select All"
|
||||
@@ -898,7 +910,8 @@
|
||||
"notices": {
|
||||
"unstable": "\u26a0 Unstable",
|
||||
"ban_risk": "\u26a0 This feature may cause bans",
|
||||
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour"
|
||||
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour",
|
||||
"randomize_device_profile_override": "Controlled by Randomized Device Profile"
|
||||
},
|
||||
"properties": {
|
||||
"downloader": {
|
||||
@@ -1174,6 +1187,14 @@
|
||||
"name": "Hide Bitmoji Presence",
|
||||
"description": "Prevents your Bitmoji from popping up while in Chat"
|
||||
},
|
||||
"spoof_viewing_gallery_presence": {
|
||||
"name": "Spoof Viewing Gallery Presence",
|
||||
"description": "Keeps your Bitmoji visible in Chat while viewing chat media"
|
||||
},
|
||||
"spoof_reply_camera_presence": {
|
||||
"name": "Spoof Reply Camera Presence",
|
||||
"description": "Keeps your Bitmoji visible in Chat while using the reply camera"
|
||||
},
|
||||
"hide_typing_notifications": {
|
||||
"name": "Hide Typing Notifications",
|
||||
"description": "Prevents anyone from knowing you're typing a message"
|
||||
@@ -2013,6 +2034,32 @@
|
||||
"name": "Force Wi-Fi Transport Flag",
|
||||
"description": "Force network transport to report Wi-Fi instead of mobile data"
|
||||
},
|
||||
"randomize_device_profile": {
|
||||
"name": "Randomized Device Profile",
|
||||
"description": "Generate and apply a full randomized device, network, locale, and settings profile in one restart-safe profile",
|
||||
"properties": {
|
||||
"show_activation_overlay": {
|
||||
"name": "Show Activation Overlay",
|
||||
"description": "Show the in-app toast when the randomized profile becomes active"
|
||||
},
|
||||
"randomize_ip_address": {
|
||||
"name": "Randomize IP Address",
|
||||
"description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created"
|
||||
},
|
||||
"persistent_app_language": {
|
||||
"name": "Persistent App Language",
|
||||
"description": "Force Snapchat to stay on a specific supported app language"
|
||||
},
|
||||
"generate_fresh_profile_action": {
|
||||
"name": "Generate Fresh Profile",
|
||||
"description": "Request a newly generated randomized profile"
|
||||
},
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_device_id": {
|
||||
"name": "Spoof Device ID",
|
||||
"description": "Override the Android ID sent to Snapchat",
|
||||
@@ -2348,7 +2395,10 @@
|
||||
"custom_android_id": {
|
||||
"null": "Use real Android ID"
|
||||
},
|
||||
"add_friend_source_spoof": {
|
||||
"persistent_app_language": {
|
||||
"system_default": "System Default"
|
||||
},
|
||||
"add_friend_source_spoof": {
|
||||
"added_by_username": "By Username",
|
||||
"added_by_mention": "By Mention",
|
||||
"added_by_group_chat": "By Group Chat",
|
||||
@@ -2965,6 +3015,10 @@
|
||||
"stopped_speaking": "Stopped Speaking",
|
||||
"started_peeking": "Started Peeking",
|
||||
"stopped_peeking": "Stopped Peeking",
|
||||
"started_using_reply_camera": "Started Using Reply Camera",
|
||||
"stopped_using_reply_camera": "Stopped Using Reply Camera",
|
||||
"started_viewing_chat_media": "Started Viewing Chat Media",
|
||||
"stopped_viewing_chat_media": "Stopped Viewing Chat Media",
|
||||
"message_read": "Message Read",
|
||||
"message_deleted": "Message Deleted",
|
||||
"message_saved": "Message Saved",
|
||||
@@ -2977,7 +3031,9 @@
|
||||
"snap_replayed_twice": "Snap Replayed Twice",
|
||||
"snap_screenshot": "Snap Screenshot",
|
||||
"snap_screen_record": "Snap Screen Record",
|
||||
"i_can_see_you": "I Can See You"
|
||||
"i_can_see_you": "I Can See You",
|
||||
"i_can_see_you_2": "I Can See You 2",
|
||||
"i_can_see_you_3": "I Can See You 3"
|
||||
},
|
||||
"cleared_from_feed": "Cleared from feed",
|
||||
"tracker_actions": {
|
||||
@@ -3186,6 +3242,10 @@
|
||||
"stopped_speaking": "{friend} stopped speaking in {conversation}",
|
||||
"started_peeking": "{friend} started peeking in {conversation}",
|
||||
"stopped_peeking": "{friend} stopped peeking in {conversation}",
|
||||
"started_using_reply_camera": "{friend} opened the reply camera in {conversation}",
|
||||
"stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}",
|
||||
"started_viewing_chat_media": "{friend} started viewing chat media in {conversation}",
|
||||
"stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}",
|
||||
"message_read": "{friend} read a message in {conversation}",
|
||||
"message_deleted": "{friend} deleted a message in {conversation}",
|
||||
"message_saved": "{friend} saved a message in {conversation}",
|
||||
@@ -3198,7 +3258,9 @@
|
||||
"snap_replayed_twice": "{friend} replayed a snap twice in {conversation}",
|
||||
"snap_screenshot": "{friend} took a screenshot in {conversation}",
|
||||
"snap_screen_record": "{friend} screen recorded in {conversation}",
|
||||
"i_can_see_you": "{friend} activity in {conversation}: {details}"
|
||||
"i_can_see_you": "{friend} activity in {conversation}: {details}",
|
||||
"i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}",
|
||||
"i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}"
|
||||
},
|
||||
"friend_mutation_observer": {
|
||||
"notification_channel_name": "Friend Mutation Observer",
|
||||
@@ -3351,6 +3413,10 @@
|
||||
"stopped_speaking": "Stopped speaking",
|
||||
"started_peeking": "Started peeking",
|
||||
"stopped_peeking": "Stopped peeking",
|
||||
"started_using_reply_camera": "Opened reply camera",
|
||||
"stopped_using_reply_camera": "Closed reply camera",
|
||||
"started_viewing_chat_media": "Started viewing chat media",
|
||||
"stopped_viewing_chat_media": "Stopped viewing chat media",
|
||||
"message_read": "Read message",
|
||||
"message_deleted": "Deleted message",
|
||||
"message_saved": "Saved message",
|
||||
@@ -3402,6 +3468,10 @@
|
||||
"stopped_speaking": "stopped speaking",
|
||||
"started_peeking": "started peeking",
|
||||
"stopped_peeking": "stopped peeking",
|
||||
"started_using_reply_camera": "opened the reply camera",
|
||||
"stopped_using_reply_camera": "closed the reply camera",
|
||||
"started_viewing_chat_media": "started viewing chat media",
|
||||
"stopped_viewing_chat_media": "stopped viewing chat media",
|
||||
"message_read": "read a message",
|
||||
"message_deleted": "deleted a message",
|
||||
"message_saved": "saved a message",
|
||||
@@ -3414,7 +3484,9 @@
|
||||
"snap_replayed_twice": "replayed a snap twice",
|
||||
"snap_screenshot": "took a screenshot",
|
||||
"snap_screen_record": "screen recorded",
|
||||
"i_can_see_you": "was active"
|
||||
"i_can_see_you": "was active",
|
||||
"i_can_see_you_2": "was viewing gallery",
|
||||
"i_can_see_you_3": "was using the reply camera"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3474,6 +3546,7 @@
|
||||
"disable_feature_loading_label": "Disable Feature Loading",
|
||||
"disable_auto_mapper_label": "Disable Auto Mapper",
|
||||
"disable_bypass_indicator_label": "Disable Bypass Indicator",
|
||||
"disable_cant_login_button_label": "Disable Can't Login Button",
|
||||
"friend_list": {
|
||||
"manage_title": "Manage Friend List",
|
||||
"export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.",
|
||||
|
||||
@@ -570,6 +570,19 @@
|
||||
"title": "Export Sensitive Data?",
|
||||
"content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)"
|
||||
},
|
||||
"randomize_device_profile": {
|
||||
"title": "Generating random device profile",
|
||||
"done": "Randomized device profile generated",
|
||||
"view_title": "Current randomized profile",
|
||||
"copied": "Randomized profile copied",
|
||||
"empty": "No generated profile is available yet. Enable the feature in Snapchat first.",
|
||||
"refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.",
|
||||
"phase": {
|
||||
"allocating": "Allocating a randomized device fingerprint",
|
||||
"network": "Preparing network, locale, and telephony values",
|
||||
"finalizing": "Finalizing the all-in-one profile and disabling manual overrides"
|
||||
}
|
||||
},
|
||||
"messaging_action": {
|
||||
"title": "Choose content types to process",
|
||||
"select_all_button": "Select All"
|
||||
@@ -1238,6 +1251,14 @@
|
||||
"name": "Hide Bitmoji Presence",
|
||||
"description": "Prevents your Bitmoji from popping up while in Chat"
|
||||
},
|
||||
"spoof_viewing_gallery_presence": {
|
||||
"name": "Spoof Viewing Gallery Presence",
|
||||
"description": "Keeps your Bitmoji visible in Chat while viewing chat media"
|
||||
},
|
||||
"spoof_reply_camera_presence": {
|
||||
"name": "Spoof Reply Camera Presence",
|
||||
"description": "Keeps your Bitmoji visible in Chat while using the reply camera"
|
||||
},
|
||||
"hide_typing_notifications": {
|
||||
"name": "Hide Typing Notifications",
|
||||
"description": "Prevents anyone from knowing you're typing a message"
|
||||
@@ -1292,6 +1313,18 @@
|
||||
"name": "Call Start Confirmation",
|
||||
"description": "Shows a confirmation dialog when starting a call"
|
||||
},
|
||||
"block_calls": {
|
||||
"name": "Block Calls",
|
||||
"description": "Blocks Snapchat call session updates so call UI and incoming call overlays do not appear"
|
||||
},
|
||||
"call_metadata_notifier": {
|
||||
"name": "Call Metadata Notifier",
|
||||
"description": "Shows a notification with captured call metadata after the call ends"
|
||||
},
|
||||
"conversation_sound_effects_style": {
|
||||
"name": "Conversation Sound Style",
|
||||
"description": "Choose the sound style used for in-conversation send and receive sounds"
|
||||
},
|
||||
"unlimited_conversation_pinning": {
|
||||
"name": "Unlimited Conversation Pinning",
|
||||
"description": "Allows you to pin an unlimited amount of conversations locally"
|
||||
@@ -1611,6 +1644,13 @@
|
||||
}
|
||||
},
|
||||
"auto_open_snaps": {
|
||||
"title": "Auto Open Snaps",
|
||||
"status_monitoring": "Monitoring",
|
||||
"status_active": "Active",
|
||||
"status_paused": "Paused",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Statistics",
|
||||
"name": "Auto Open Snaps Settings",
|
||||
"description": "Configure delay and queue settings for Auto Open Snaps",
|
||||
"properties": {
|
||||
@@ -1637,7 +1677,34 @@
|
||||
"retry_delay": {
|
||||
"name": "Retry Delay (ms)",
|
||||
"description": "Delay in milliseconds between retry attempts"
|
||||
}
|
||||
},
|
||||
"compact_notification": {
|
||||
"name": "Auto Open Compact Notification",
|
||||
"description": "Use a smaller, single-line notification for status updates"
|
||||
},
|
||||
"show_progress_bar": {
|
||||
"name": "Show Progress Bar",
|
||||
"description": "Display a visual progress bar in the status notification"
|
||||
},
|
||||
"show_lifetime_stats": {
|
||||
"name": "Show Lifetime Statistics",
|
||||
"description": "Include the total number of snaps opened since installation in the notification"
|
||||
},
|
||||
"show_queue_preview": {
|
||||
"name": "Show Queue Preview",
|
||||
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
|
||||
},
|
||||
"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_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
"description": "Configure a specific time window where the engine will throttle its speed."
|
||||
},
|
||||
"sleep_window": {
|
||||
"name": "Auto Open Scheduler",
|
||||
"description": "Define the start and end times for scheduled throttled processing."
|
||||
},
|
||||
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
|
||||
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
|
||||
}
|
||||
},
|
||||
"auto_delete_sent_messages": {
|
||||
@@ -1989,9 +2056,17 @@
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"unlock_zoom_limit": {
|
||||
"name": "Unlock Zoom Limit",
|
||||
"description": "Overrides the max camera zoom Snapchat reads from the device"
|
||||
},
|
||||
"max_zoom_override": {
|
||||
"name": "Max Zoom Override",
|
||||
"description": "Maximum zoom ratio to report to Snapchat, for example 120"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2081,6 +2156,418 @@
|
||||
"name": "Force Wi-Fi Transport Flag",
|
||||
"description": "Force network transport to report Wi-Fi instead of mobile data"
|
||||
},
|
||||
"randomize_device_profile": {
|
||||
"name": "Randomized Device Profile",
|
||||
"description": "Generate and apply a full randomized device, network, locale, and settings profile",
|
||||
"properties": {
|
||||
"show_activation_overlay": {
|
||||
"name": "Show Activation Overlay",
|
||||
"description": "Show the in-app toast when the randomized profile becomes active"
|
||||
},
|
||||
"randomize_ip_address": {
|
||||
"name": "Randomize IP Address",
|
||||
"description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created"
|
||||
},
|
||||
"spoof_build_properties": {
|
||||
"name": "Spoof Build Properties",
|
||||
"description": "Apply randomized build fields, fingerprints, and device property values"
|
||||
},
|
||||
"build_properties": {
|
||||
"name": "Build Properties",
|
||||
"description": "Enable build property spoofing and fine-tune its subsets",
|
||||
"properties": {
|
||||
"device_identity": {
|
||||
"name": "Device Identity",
|
||||
"description": "Enable device identity spoofing and fine-tune its values",
|
||||
"properties": {
|
||||
"manufacturer_model": {
|
||||
"name": "Manufacturer And Model",
|
||||
"description": "Randomize the reported manufacturer and model"
|
||||
},
|
||||
"brand_product": {
|
||||
"name": "Brand And Product",
|
||||
"description": "Randomize the reported brand, device, and product values"
|
||||
},
|
||||
"hardware_board": {
|
||||
"name": "Hardware And Board",
|
||||
"description": "Randomize the reported hardware and board values"
|
||||
}
|
||||
}
|
||||
},
|
||||
"build_version": {
|
||||
"name": "Build Version",
|
||||
"description": "Enable build version spoofing and fine-tune its values",
|
||||
"properties": {
|
||||
"fingerprint": {
|
||||
"name": "Fingerprint",
|
||||
"description": "Randomize the reported build fingerprint"
|
||||
},
|
||||
"display": {
|
||||
"name": "Display ID",
|
||||
"description": "Randomize the reported build display ID"
|
||||
},
|
||||
"host": {
|
||||
"name": "Host",
|
||||
"description": "Randomize the reported build host"
|
||||
},
|
||||
"bootloader": {
|
||||
"name": "Bootloader",
|
||||
"description": "Randomize the reported bootloader value"
|
||||
},
|
||||
"build_time": {
|
||||
"name": "Build Time",
|
||||
"description": "Randomize the reported build timestamp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abi_lists": {
|
||||
"name": "ABI Lists",
|
||||
"description": "Enable ABI spoofing and fine-tune its values",
|
||||
"properties": {
|
||||
"combined_abis": {
|
||||
"name": "Combined ABI List",
|
||||
"description": "Randomize the combined supported ABI list"
|
||||
},
|
||||
"split_abis": {
|
||||
"name": "32-bit And 64-bit ABI Lists",
|
||||
"description": "Randomize the split 32-bit and 64-bit ABI lists"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system_properties": {
|
||||
"name": "System Properties",
|
||||
"description": "Expose randomized values through Android system property lookups",
|
||||
"properties": {
|
||||
"build": {
|
||||
"name": "Build Properties",
|
||||
"description": "Expose randomized build values through system properties"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Locale Properties",
|
||||
"description": "Expose randomized locale values through system properties"
|
||||
},
|
||||
"telephony": {
|
||||
"name": "Telephony Properties",
|
||||
"description": "Expose randomized telephony values through system properties"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_locale": {
|
||||
"name": "Spoof Locale",
|
||||
"description": "Apply the randomized locale and language hooks"
|
||||
},
|
||||
"locale_options": {
|
||||
"name": "Locale Details",
|
||||
"description": "Enable locale spoofing and fine-tune its subsets",
|
||||
"properties": {
|
||||
"locale": {
|
||||
"name": "Locale",
|
||||
"description": "Enable locale spoofing and fine-tune language and region values",
|
||||
"properties": {
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"description": "Randomize the reported language value"
|
||||
},
|
||||
"region": {
|
||||
"name": "Region",
|
||||
"description": "Randomize the reported region value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"name": "Time",
|
||||
"description": "Enable time spoofing and fine-tune time-related values",
|
||||
"properties": {
|
||||
"time_zone_id": {
|
||||
"name": "Time Zone ID",
|
||||
"description": "Randomize the reported time zone ID"
|
||||
},
|
||||
"time_zone_display_name": {
|
||||
"name": "Time Zone Display Name",
|
||||
"description": "Randomize the reported time zone display name"
|
||||
},
|
||||
"auto_time": {
|
||||
"name": "Auto Time",
|
||||
"description": "Randomize the global auto-time setting"
|
||||
},
|
||||
"auto_time_zone": {
|
||||
"name": "Auto Time Zone",
|
||||
"description": "Randomize the global auto-time-zone setting"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_telephony": {
|
||||
"name": "Spoof Telephony",
|
||||
"description": "Apply randomized carrier, SIM, and phone capability values"
|
||||
},
|
||||
"telephony_options": {
|
||||
"name": "Telephony Details",
|
||||
"description": "Enable telephony spoofing and fine-tune its subsets",
|
||||
"properties": {
|
||||
"mms": {
|
||||
"name": "MMS",
|
||||
"description": "Enable MMS spoofing and fine-tune MMS values",
|
||||
"properties": {
|
||||
"user_agent": {
|
||||
"name": "User Agent",
|
||||
"description": "Randomize the MMS user agent string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"network_identity": {
|
||||
"name": "Network Identity",
|
||||
"description": "Enable network identity spoofing and fine-tune network values",
|
||||
"properties": {
|
||||
"network_type": {
|
||||
"name": "Network Type",
|
||||
"description": "Randomize the reported network type"
|
||||
},
|
||||
"operator_numeric": {
|
||||
"name": "Operator Numeric",
|
||||
"description": "Randomize the reported operator numeric code"
|
||||
},
|
||||
"operator_name": {
|
||||
"name": "Operator Name",
|
||||
"description": "Randomize the reported operator name"
|
||||
},
|
||||
"country_iso": {
|
||||
"name": "Country ISO",
|
||||
"description": "Randomize the reported network country ISO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sim_identity": {
|
||||
"name": "SIM Identity",
|
||||
"description": "Enable SIM identity spoofing and fine-tune SIM values",
|
||||
"properties": {
|
||||
"country_iso": {
|
||||
"name": "Country ISO",
|
||||
"description": "Randomize the reported SIM country ISO"
|
||||
},
|
||||
"operator_numeric": {
|
||||
"name": "Operator Numeric",
|
||||
"description": "Randomize the reported SIM operator numeric code"
|
||||
},
|
||||
"operator_name": {
|
||||
"name": "Operator Name",
|
||||
"description": "Randomize the reported SIM operator name"
|
||||
},
|
||||
"sim_state": {
|
||||
"name": "SIM State",
|
||||
"description": "Randomize the reported SIM state"
|
||||
},
|
||||
"has_icc_card": {
|
||||
"name": "ICC Card",
|
||||
"description": "Randomize whether a SIM card is reported as present"
|
||||
}
|
||||
}
|
||||
},
|
||||
"phone_capabilities": {
|
||||
"name": "Phone Capabilities",
|
||||
"description": "Enable phone capability spoofing and fine-tune capability values",
|
||||
"properties": {
|
||||
"phone_count": {
|
||||
"name": "Phone Count",
|
||||
"description": "Randomize the reported phone count"
|
||||
},
|
||||
"hearing_aid": {
|
||||
"name": "Hearing Aid",
|
||||
"description": "Randomize hearing aid compatibility support"
|
||||
},
|
||||
"tty": {
|
||||
"name": "TTY",
|
||||
"description": "Randomize TTY support"
|
||||
},
|
||||
"world_phone": {
|
||||
"name": "World Phone",
|
||||
"description": "Randomize world phone support"
|
||||
},
|
||||
"roaming": {
|
||||
"name": "Roaming",
|
||||
"description": "Randomize roaming status"
|
||||
},
|
||||
"sms_voice": {
|
||||
"name": "SMS And Voice",
|
||||
"description": "Randomize SMS and voice capability support"
|
||||
},
|
||||
"phone_type": {
|
||||
"name": "Phone Type",
|
||||
"description": "Randomize the reported phone type"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_settings": {
|
||||
"name": "Spoof Settings",
|
||||
"description": "Apply the randomized Android settings overrides"
|
||||
},
|
||||
"settings_options": {
|
||||
"name": "Settings Details",
|
||||
"description": "Enable settings spoofing and fine-tune its namespaces",
|
||||
"properties": {
|
||||
"secure": {
|
||||
"name": "Secure Settings",
|
||||
"description": "Enable secure settings spoofing and fine-tune secure values",
|
||||
"properties": {
|
||||
"base": {
|
||||
"name": "Base",
|
||||
"description": "Randomize the base secure settings values"
|
||||
},
|
||||
"tts": {
|
||||
"name": "Text To Speech",
|
||||
"description": "Randomize text-to-speech secure settings values"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"name": "System Settings",
|
||||
"description": "Enable system settings spoofing and fine-tune system values",
|
||||
"properties": {
|
||||
"base": {
|
||||
"name": "Base",
|
||||
"description": "Randomize the base system settings values"
|
||||
},
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth",
|
||||
"description": "Randomize Bluetooth-related system settings values"
|
||||
}
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"name": "Global Settings",
|
||||
"description": "Enable global settings spoofing and fine-tune global values",
|
||||
"properties": {
|
||||
"base": {
|
||||
"name": "Base",
|
||||
"description": "Randomize the base global settings values"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_network": {
|
||||
"name": "Spoof Network",
|
||||
"description": "Apply randomized Wi-Fi and DNS network values"
|
||||
},
|
||||
"network_options": {
|
||||
"name": "Network Details",
|
||||
"description": "Enable network spoofing and fine-tune its subsets",
|
||||
"properties": {
|
||||
"wifi": {
|
||||
"name": "Wi-Fi Info",
|
||||
"description": "Enable Wi-Fi spoofing and fine-tune Wi-Fi values",
|
||||
"properties": {
|
||||
"ssid": {
|
||||
"name": "SSID",
|
||||
"description": "Randomize the reported Wi-Fi SSID"
|
||||
},
|
||||
"rssi": {
|
||||
"name": "Signal Strength",
|
||||
"description": "Randomize the reported Wi-Fi RSSI value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"name": "DNS",
|
||||
"description": "Enable DNS spoofing and fine-tune DNS values",
|
||||
"properties": {
|
||||
"servers": {
|
||||
"name": "Servers",
|
||||
"description": "Randomize the reported DNS server list"
|
||||
},
|
||||
"search_domains": {
|
||||
"name": "Search Domains",
|
||||
"description": "Randomize the reported DNS search domains"
|
||||
},
|
||||
"private_dns": {
|
||||
"name": "Private DNS",
|
||||
"description": "Randomize the reported private DNS values"
|
||||
}
|
||||
}
|
||||
},
|
||||
"captive_portal": {
|
||||
"name": "Captive Portal",
|
||||
"description": "Enable captive portal spoofing and fine-tune portal values",
|
||||
"properties": {
|
||||
"capability": {
|
||||
"name": "Capability",
|
||||
"description": "Randomize the reported captive portal capability"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_identifiers": {
|
||||
"name": "Spoof Identifiers",
|
||||
"description": "Apply randomized Android ID, advertising ID, and hardware address overrides"
|
||||
},
|
||||
"identifier_options": {
|
||||
"name": "Identifier Details",
|
||||
"description": "Enable identifier spoofing and fine-tune its subsets",
|
||||
"properties": {
|
||||
"android_id": {
|
||||
"name": "Android ID",
|
||||
"description": "Enable Android ID spoofing and fine-tune Android ID values",
|
||||
"properties": {
|
||||
"string_value": {
|
||||
"name": "String Value",
|
||||
"description": "Randomize the string Android ID value"
|
||||
},
|
||||
"long_value": {
|
||||
"name": "Long Value",
|
||||
"description": "Randomize the long Android ID value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"advertising_id": {
|
||||
"name": "Advertising ID",
|
||||
"description": "Enable advertising ID spoofing and fine-tune advertising ID values",
|
||||
"properties": {
|
||||
"settings_value": {
|
||||
"name": "Settings Value",
|
||||
"description": "Randomize the advertising ID returned through settings"
|
||||
},
|
||||
"play_services": {
|
||||
"name": "Play Services",
|
||||
"description": "Randomize the advertising ID returned through Play Services"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hardware_addresses": {
|
||||
"name": "Hardware Addresses",
|
||||
"description": "Enable hardware address spoofing and fine-tune address values",
|
||||
"properties": {
|
||||
"wifi_mac": {
|
||||
"name": "Wi-Fi MAC",
|
||||
"description": "Randomize the reported Wi-Fi MAC address"
|
||||
},
|
||||
"bluetooth_mac": {
|
||||
"name": "Bluetooth MAC",
|
||||
"description": "Randomize the reported Bluetooth MAC address"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent_app_language": {
|
||||
"name": "Persistent App Language",
|
||||
"description": "Force Snapchat to stay on a specific supported app language"
|
||||
},
|
||||
"generate_fresh_profile_action": {
|
||||
"name": "Generate Fresh Profile",
|
||||
"description": "Request a newly generated randomized profile"
|
||||
},
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spoof_device_id": {
|
||||
"name": "Spoof Device ID",
|
||||
"description": "Override the Android ID sent to Snapchat",
|
||||
@@ -2127,7 +2614,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -2433,6 +2920,9 @@
|
||||
"custom_android_id": {
|
||||
"null": "Use real Android ID"
|
||||
},
|
||||
"persistent_app_language": {
|
||||
"system_default": "System Default"
|
||||
},
|
||||
"add_friend_source_spoof": {
|
||||
"added_by_username": "By Username",
|
||||
"added_by_mention": "By Mention",
|
||||
@@ -2486,6 +2976,13 @@
|
||||
"back_custom_frame_rate": {
|
||||
"null": "Device default FPS"
|
||||
},
|
||||
"conversation_sound_effects_style": {
|
||||
"disabled": "Disabled",
|
||||
"imessage": "iMessage",
|
||||
"telegram": "Telegram",
|
||||
"whatsapp": "WhatsApp",
|
||||
"subtle": "Subtle"
|
||||
},
|
||||
"force_voice_note_format": {
|
||||
"null": "Use Snapchat default"
|
||||
},
|
||||
@@ -2864,7 +3361,9 @@
|
||||
"download_button": "Download",
|
||||
"delete_logged_message_button": "Delete Logged Message",
|
||||
"show_chat_edit_history": "Show Chat Edit History",
|
||||
"convert_message": "Convert Message"
|
||||
"convert_message": "Convert Message",
|
||||
"pin_local_message": "Pin Message Locally",
|
||||
"unpin_local_message": "Unpin Local Message"
|
||||
},
|
||||
"chat_wallpaper_downloader": {
|
||||
"download_button": "Download Chat Wallpaper"
|
||||
@@ -3038,6 +3537,7 @@
|
||||
"positive": "Yes",
|
||||
"negative": "No",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"save": "Save",
|
||||
"open": "Open",
|
||||
"download": "Download",
|
||||
@@ -3055,6 +3555,10 @@
|
||||
"stopped_speaking": "Stopped Speaking",
|
||||
"started_peeking": "Started Peeking",
|
||||
"stopped_peeking": "Stopped Peeking",
|
||||
"started_using_reply_camera": "Started Using Reply Camera",
|
||||
"stopped_using_reply_camera": "Stopped Using Reply Camera",
|
||||
"started_viewing_chat_media": "Started Viewing Chat Media",
|
||||
"stopped_viewing_chat_media": "Stopped Viewing Chat Media",
|
||||
"message_read": "Message Read",
|
||||
"message_deleted": "Message Deleted",
|
||||
"message_saved": "Message Saved",
|
||||
@@ -3067,7 +3571,9 @@
|
||||
"snap_replayed_twice": "Snap Replayed Twice",
|
||||
"snap_screenshot": "Snap Screenshot",
|
||||
"snap_screen_record": "Snap Screen Record",
|
||||
"i_can_see_you": "I Can See You"
|
||||
"i_can_see_you": "I Can See You",
|
||||
"i_can_see_you_2": "I Can See You 2",
|
||||
"i_can_see_you_3": "I Can See You 3"
|
||||
},
|
||||
"cleared_from_feed": "Cleared from feed",
|
||||
"tracker_actions": {
|
||||
@@ -3104,6 +3610,16 @@
|
||||
"dialog_title": "Start Call",
|
||||
"dialog_message": "Are you sure you want to start a call?"
|
||||
},
|
||||
"call_metadata_notifier": {
|
||||
"notification_channel_name": "Call Metadata",
|
||||
"notification_title": "Call Metadata Captured",
|
||||
"notification_empty": "No call metadata was captured"
|
||||
},
|
||||
"local_pinned_messages": {
|
||||
"banner_title": "Pinned Message",
|
||||
"pinned_toast": "Pinned message locally",
|
||||
"unpinned_toast": "Removed local pinned message"
|
||||
},
|
||||
"half_swipe_notifier": {
|
||||
"notification_channel_name": "Half Swipe",
|
||||
"notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds",
|
||||
@@ -3199,9 +3715,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",
|
||||
"priority_title": "Auto Open Snaps (Priority)",
|
||||
"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_schedule": {
|
||||
"title": "Auto Open Scheduler",
|
||||
"start": "Start",
|
||||
"end": "End"
|
||||
},
|
||||
"error_title": "Auto Open Snaps (Errors)",
|
||||
"channel_description": "Notifications for auto-opening snaps queue status",
|
||||
"priority_channel_description": "High priority notifications for auto-opening snaps",
|
||||
@@ -3222,6 +3741,16 @@
|
||||
"status_paused": "Paused",
|
||||
"status_monitoring": "Monitoring",
|
||||
"status_active": "Active",
|
||||
"status_failed": "Failed to open {sender}",
|
||||
"status_retrying": "Retrying in background...",
|
||||
"processing_speed_full": "Full Speed",
|
||||
"processing_speed": "Processing Speed",
|
||||
"speed_throttled": "Throttled",
|
||||
"estimated_time": "Estimated Time",
|
||||
"notification_statistics": "STATISTICS",
|
||||
"notification_total_opened": "Lifetime Opened",
|
||||
"notification_queue_preview": "QUEUE PREVIEW",
|
||||
"notification_no_snaps_queue": "Monitoring snaps in background...",
|
||||
"queue_cleared": "Queue cleared and statistics reset",
|
||||
"queue_cleared_title": "Queue cleared",
|
||||
"queue_cleared_reset": "Queue Cleared & Reset",
|
||||
@@ -3295,6 +3824,10 @@
|
||||
"stopped_speaking": "{friend} stopped speaking in {conversation}",
|
||||
"started_peeking": "{friend} started peeking in {conversation}",
|
||||
"stopped_peeking": "{friend} stopped peeking in {conversation}",
|
||||
"started_using_reply_camera": "{friend} opened the reply camera in {conversation}",
|
||||
"stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}",
|
||||
"started_viewing_chat_media": "{friend} started viewing chat media in {conversation}",
|
||||
"stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}",
|
||||
"message_read": "{friend} read a message in {conversation}",
|
||||
"message_deleted": "{friend} deleted a message in {conversation}",
|
||||
"message_saved": "{friend} saved a message in {conversation}",
|
||||
@@ -3307,7 +3840,9 @@
|
||||
"snap_replayed_twice": "{friend} replayed a snap twice in {conversation}",
|
||||
"snap_screenshot": "{friend} took a screenshot in {conversation}",
|
||||
"snap_screen_record": "{friend} screen recorded in {conversation}",
|
||||
"i_can_see_you": "{friend} activity in {conversation}: {details}"
|
||||
"i_can_see_you": "{friend} activity in {conversation}: {details}",
|
||||
"i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}",
|
||||
"i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}"
|
||||
},
|
||||
"friend_mutation_observer": {
|
||||
"notification_channel_name": "Friend Mutation Observer",
|
||||
@@ -3467,9 +4002,13 @@
|
||||
"started_typing": "Started typing",
|
||||
"stopped_typing": "Stopped typing",
|
||||
"started_speaking": "Started speaking",
|
||||
"stopped_speaking": "Stopped speaking",
|
||||
"stopped_speaking": "Stopped speaking",
|
||||
"started_peeking": "Started peeking",
|
||||
"stopped_peeking": "Stopped peeking",
|
||||
"started_using_reply_camera": "Opened reply camera",
|
||||
"stopped_using_reply_camera": "Closed reply camera",
|
||||
"started_viewing_chat_media": "Started viewing chat media",
|
||||
"stopped_viewing_chat_media": "Stopped viewing chat media",
|
||||
"message_read": "Read message",
|
||||
"message_deleted": "Deleted message",
|
||||
"message_saved": "Saved message",
|
||||
@@ -3521,6 +4060,10 @@
|
||||
"stopped_speaking": "stopped speaking",
|
||||
"started_peeking": "started peeking",
|
||||
"stopped_peeking": "stopped peeking",
|
||||
"started_using_reply_camera": "opened the reply camera",
|
||||
"stopped_using_reply_camera": "closed the reply camera",
|
||||
"started_viewing_chat_media": "started viewing chat media",
|
||||
"stopped_viewing_chat_media": "stopped viewing chat media",
|
||||
"message_read": "read a message",
|
||||
"message_deleted": "deleted a message",
|
||||
"message_saved": "saved a message",
|
||||
@@ -3533,7 +4076,9 @@
|
||||
"snap_replayed_twice": "replayed a snap twice",
|
||||
"snap_screenshot": "took a screenshot",
|
||||
"snap_screen_record": "screen recorded",
|
||||
"i_can_see_you": "was active"
|
||||
"i_can_see_you": "was active",
|
||||
"i_can_see_you_2": "was viewing gallery",
|
||||
"i_can_see_you_3": "was using the reply camera"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3593,6 +4138,7 @@
|
||||
"disable_feature_loading_label": "Disable Feature Loading",
|
||||
"disable_auto_mapper_label": "Disable Auto Mapper",
|
||||
"disable_bypass_indicator_label": "Disable Bypass Indicator",
|
||||
"disable_cant_login_button_label": "Disable Can't Login Button",
|
||||
"friend_list": {
|
||||
"manage_title": "Manage Friend List",
|
||||
"export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.",
|
||||
@@ -3631,17 +4177,25 @@
|
||||
"include_saved_locations_description": "Export your saved location coordinates",
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"close": "Close",
|
||||
"add": "Add",
|
||||
"ok": "OK",
|
||||
"quit": "Quit",
|
||||
"done": "Done",
|
||||
"back": "Back",
|
||||
"message": "Message",
|
||||
"type_message": "Type message...",
|
||||
"unknown": "Unknown",
|
||||
"unknown_error": "Unknown error",
|
||||
"not_available": "N/A",
|
||||
"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",
|
||||
@@ -3768,4 +4322,30 @@
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter"
|
||||
}
|
||||
,
|
||||
"tasks_no_tasks": "No tasks",
|
||||
"tasks_no_active_tasks": "No active tasks",
|
||||
"tasks_no_scheduled_tasks": "No scheduled snaps",
|
||||
"tasks_tab_active": "Active",
|
||||
"tasks_tab_scheduled": "Scheduled",
|
||||
"tasks_clear_button_description": "Clear tasks",
|
||||
"tasks_delete_button": "Delete",
|
||||
"tasks_merge_button": "Merge",
|
||||
"tasks_summary_active": "{active} active · {recent} recent",
|
||||
"tasks_summary_idle": "Idle · {recent} recent",
|
||||
"tasks_running_count": "{count} running",
|
||||
"tasks_tagline": "Monitor and manage background actions",
|
||||
"tasks_failed_to_open_file": "Failed to open file",
|
||||
"tasks_merge_files_toast": "Merging {count} files",
|
||||
"tasks_remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
|
||||
"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."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,15 @@ class Camera : ConfigContainer() {
|
||||
val overrideFrontResolution get() = _overrideFrontResolution
|
||||
val overrideBackResolution get() = _overrideBackResolution
|
||||
val videoRecordTimer = boolean("video_record_timer")
|
||||
val unlockZoomLimit = boolean("unlock_zoom_limit") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val maxZoomOverride = float("max_zoom_override", 120f) {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
inputCheck = { (it.toFloatOrNull() ?: 0f) in 1f..500f }
|
||||
}
|
||||
|
||||
val audioVideoOptimizations = boolean("audio_video", defaultValue = true) { requireRestart() }
|
||||
val cameraOptimizations = boolean("camera_tweaks", defaultValue = false) { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
|
||||
|
||||
val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } }
|
||||
}
|
||||
|
||||
@@ -63,11 +63,12 @@ class Experimental : ConfigContainer() {
|
||||
}
|
||||
|
||||
val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
|
||||
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
|
||||
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; requireRestart() }
|
||||
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
|
||||
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }
|
||||
val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val networkOptimization = boolean("network_optimization") { requireRestart() }
|
||||
val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() }
|
||||
val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() }
|
||||
val friendNotes = boolean("friend_notes") { requireRestart() }
|
||||
|
||||
@@ -175,7 +175,20 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val retryDelay = integer("retry_delay", defaultValue = 3000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
|
||||
}
|
||||
|
||||
val compactNotification = boolean("compact_notification", false)
|
||||
val showProgressBar = boolean("show_progress_bar", true)
|
||||
val showLifetimeStats = boolean("show_lifetime_stats", false)
|
||||
val showQueuePreview = boolean("show_queue_preview", true)
|
||||
|
||||
// Resource Intelligence: Smart triggers for battery and data safety
|
||||
val onlyOnWifi = boolean("only_on_wifi", false)
|
||||
val pauseDuringGaming = boolean("pause_during_gaming", false)
|
||||
val safeProcessing = boolean("safe_processing", true)
|
||||
val onlyWhenIdle = boolean("only_when_idle", false)
|
||||
val sleepWindow = string("sleep_window", defaultValue = "23:00-07:00") {
|
||||
addFlags(ConfigFlag.NO_DISABLE_KEY)
|
||||
inputCheck = { it.matches(Regex("^([01]\\d|2[0-3]):([0-5]\\d)-([01]\\d|2[0-3]):([0-5]\\d)$")) }
|
||||
}
|
||||
}
|
||||
|
||||
class AutoDeleteSentMessagesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
@@ -201,6 +214,8 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
|
||||
val hidePeekAPeek = boolean("hide_peek_a_peek")
|
||||
val hideBitmojiPresence = boolean("hide_bitmoji_presence")
|
||||
val spoofViewingGalleryPresence = boolean("spoof_viewing_gallery_presence")
|
||||
val spoofReplyCameraPresence = boolean("spoof_reply_camera_presence")
|
||||
val hideTypingNotifications = boolean("hide_typing_notifications")
|
||||
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
|
||||
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
|
||||
@@ -216,6 +231,14 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val disableReplayInFF = boolean("disable_replay_in_ff")
|
||||
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
|
||||
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
|
||||
val blockCalls = boolean("block_calls") { requireRestart() }
|
||||
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
|
||||
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
|
||||
requireRestart()
|
||||
customOptionTranslationPath = "conversation_sound_effects_style"
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
addFlags(ConfigFlag.NO_DISABLE_KEY)
|
||||
}.apply { set("disabled") }
|
||||
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
|
||||
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
|
||||
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",
|
||||
@@ -310,4 +333,3 @@ class MessagingTweaks : ConfigContainer() {
|
||||
|
||||
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,193 @@ import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
|
||||
class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
companion object {
|
||||
val supportedSnapchatLanguages = listOf(
|
||||
"ar", "bn", "bn-BD", "bn-IN", "da", "de", "el", "en-GB", "es", "es-AR", "es-ES", "es-MX",
|
||||
"fi", "fil", "fil-PH", "fr", "gu", "gu-IN", "hi", "hi-IN", "in", "it", "ja", "kn", "kn-IN",
|
||||
"ko", "ml", "ml-IN", "mr", "mr-IN", "ms", "ms-MY", "nb", "nl", "pa", "pa-IN", "pl", "pt",
|
||||
"pt-PT", "ro", "ru", "sv", "ta", "ta-IN", "te", "te-IN", "th", "th-TH", "tr", "ur", "ur-PK",
|
||||
"vi", "vi-VN", "zh", "zh-CN", "zh-TW"
|
||||
)
|
||||
}
|
||||
|
||||
inner class RandomizedDeviceProfileConfig : ConfigContainer(hasGlobalState = true) {
|
||||
inner class RandomizedBuildVersionConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val fingerprint = boolean("fingerprint", defaultValue = true) { requireRestart() }
|
||||
val display = boolean("display", defaultValue = true) { requireRestart() }
|
||||
val host = boolean("host", defaultValue = true) { requireRestart() }
|
||||
val bootloader = boolean("bootloader", defaultValue = true) { requireRestart() }
|
||||
val buildTime = boolean("build_time", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedDeviceIdentityConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val manufacturerModel = boolean("manufacturer_model", defaultValue = true) { requireRestart() }
|
||||
val brandProduct = boolean("brand_product", defaultValue = true) { requireRestart() }
|
||||
val hardwareBoard = boolean("hardware_board", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedAbiListsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val combinedAbis = boolean("combined_abis", defaultValue = true) { requireRestart() }
|
||||
val splitAbis = boolean("split_abis", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedSystemPropertiesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val build = boolean("build", defaultValue = true) { requireRestart() }
|
||||
val locale = boolean("locale", defaultValue = true) { requireRestart() }
|
||||
val telephony = boolean("telephony", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedBuildPropertiesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val deviceIdentity = container("device_identity", RandomizedDeviceIdentityConfig().apply { globalState = true })
|
||||
val abiLists = container("abi_lists", RandomizedAbiListsConfig().apply { globalState = true })
|
||||
val systemProperties = container("system_properties", RandomizedSystemPropertiesConfig().apply { globalState = true })
|
||||
val buildVersion = container("build_version", RandomizedBuildVersionConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedLocaleValueConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val language = boolean("language", defaultValue = true) { requireRestart() }
|
||||
val region = boolean("region", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedTimeConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val timeZoneId = boolean("time_zone_id", defaultValue = false) { requireRestart() }
|
||||
val timeZoneDisplayName = boolean("time_zone_display_name", defaultValue = false) { requireRestart() }
|
||||
val autoTime = boolean("auto_time", defaultValue = false) { requireRestart() }
|
||||
val autoTimeZone = boolean("auto_time_zone", defaultValue = false) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedLocaleConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val locale = container("locale", RandomizedLocaleValueConfig().apply { globalState = true })
|
||||
val time = container("time", RandomizedTimeConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedMmsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val userAgent = boolean("user_agent", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedNetworkIdentityConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val networkType = boolean("network_type", defaultValue = true) { requireRestart() }
|
||||
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
|
||||
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
|
||||
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedSimIdentityConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
|
||||
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
|
||||
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
|
||||
val simState = boolean("sim_state", defaultValue = true) { requireRestart() }
|
||||
val hasIccCard = boolean("has_icc_card", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedPhoneCapabilitiesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val phoneCount = boolean("phone_count", defaultValue = true) { requireRestart() }
|
||||
val hearingAid = boolean("hearing_aid", defaultValue = true) { requireRestart() }
|
||||
val tty = boolean("tty", defaultValue = true) { requireRestart() }
|
||||
val worldPhone = boolean("world_phone", defaultValue = true) { requireRestart() }
|
||||
val roaming = boolean("roaming", defaultValue = true) { requireRestart() }
|
||||
val smsVoice = boolean("sms_voice", defaultValue = true) { requireRestart() }
|
||||
val phoneType = boolean("phone_type", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedTelephonyConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val mms = container("mms", RandomizedMmsConfig().apply { globalState = true })
|
||||
val networkIdentity = container("network_identity", RandomizedNetworkIdentityConfig().apply { globalState = true })
|
||||
val simIdentity = container("sim_identity", RandomizedSimIdentityConfig().apply { globalState = true })
|
||||
val phoneCapabilities = container("phone_capabilities", RandomizedPhoneCapabilitiesConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedSecureSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val base = boolean("base", defaultValue = true) { requireRestart() }
|
||||
val tts = boolean("tts", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedSystemSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val base = boolean("base", defaultValue = true) { requireRestart() }
|
||||
val bluetooth = boolean("bluetooth", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedGlobalSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val base = boolean("base", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val secure = container("secure", RandomizedSecureSettingsConfig().apply { globalState = true })
|
||||
val system = container("system", RandomizedSystemSettingsConfig().apply { globalState = true })
|
||||
val global = container("global", RandomizedGlobalSettingsConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val ssid = boolean("ssid", defaultValue = true) { requireRestart() }
|
||||
val rssi = boolean("rssi", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedDnsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val servers = boolean("servers", defaultValue = true) { requireRestart() }
|
||||
val searchDomains = boolean("search_domains", defaultValue = true) { requireRestart() }
|
||||
val privateDns = boolean("private_dns", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedCaptivePortalConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val capability = boolean("capability", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedNetworkConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val wifi = container("wifi", RandomizedWifiConfig().apply { globalState = true })
|
||||
val dns = container("dns", RandomizedDnsConfig().apply { globalState = true })
|
||||
val captivePortal = container("captive_portal", RandomizedCaptivePortalConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedAndroidIdConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val stringValue = boolean("string_value", defaultValue = true) { requireRestart() }
|
||||
val longValue = boolean("long_value", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedAdvertisingIdConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val settingsValue = boolean("settings_value", defaultValue = true) { requireRestart() }
|
||||
val playServices = boolean("play_services", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedHardwareAddressesConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val wifiMac = boolean("wifi_mac", defaultValue = true) { requireRestart() }
|
||||
val bluetoothMac = boolean("bluetooth_mac", defaultValue = true) { requireRestart() }
|
||||
}
|
||||
|
||||
inner class RandomizedIdentifiersConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val androidId = container("android_id", RandomizedAndroidIdConfig().apply { globalState = true })
|
||||
val advertisingId = container("advertising_id", RandomizedAdvertisingIdConfig().apply { globalState = true })
|
||||
val hardwareAddresses = container("hardware_addresses", RandomizedHardwareAddressesConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
val showActivationOverlay = boolean("show_activation_overlay", defaultValue = false)
|
||||
val randomizeIpAddress = boolean("randomize_ip_address", defaultValue = false) { requireRestart() }
|
||||
val buildProperties = container("build_properties", RandomizedBuildPropertiesConfig().apply { globalState = true })
|
||||
val localeOptions = container("locale_options", RandomizedLocaleConfig().apply { globalState = true })
|
||||
val telephonyOptions = container("telephony_options", RandomizedTelephonyConfig().apply { globalState = true })
|
||||
val settingsOptions = container("settings_options", RandomizedSettingsConfig().apply { globalState = true })
|
||||
val networkOptions = container("network_options", RandomizedNetworkConfig().apply { globalState = true })
|
||||
val identifierOptions = container("identifier_options", RandomizedIdentifiersConfig().apply { globalState = true })
|
||||
val persistentAppLanguage = unique("persistent_app_language", *supportedSnapchatLanguages.toTypedArray()) {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
disabledKey = "system_default"
|
||||
customOptionTranslationPath = "features.options.persistent_app_language"
|
||||
}
|
||||
val generateFreshProfileAction = string("generate_fresh_profile_action")
|
||||
val viewCurrentProfileAction = string("view_current_profile_action")
|
||||
val profileGenerationToken = string("profile_generation_token") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
val currentProfileSnapshot = string("current_profile_snapshot") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
inner class SpoofDeviceIdConfig : ConfigContainer() {
|
||||
val spoofAndroidId = boolean("spoof_android_id") { requireRestart() }
|
||||
val spoofAndroidId = boolean("spoof_android_id") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
|
||||
val customAndroidId = string("custom_android_id") {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) }
|
||||
}
|
||||
}
|
||||
@@ -16,8 +199,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() }
|
||||
val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() }
|
||||
val forceWifiTransportFlag = boolean("force_wifi_transport_flag") { requireRestart() }
|
||||
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
|
||||
val spoofDevice = boolean("spoof_device") { requireRestart() }
|
||||
val randomizeDeviceProfile = container("randomize_device_profile", RandomizedDeviceProfileConfig()) { requireRestart() }
|
||||
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
|
||||
val spoofDevice = boolean("spoof_device") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
|
||||
val deviceModel = unique("device_model",
|
||||
"none",
|
||||
"random",
|
||||
@@ -38,6 +222,7 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
"realme GT 6"
|
||||
) {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
customOptionTranslationPath = "features.options.device_model"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ enum class FileType(
|
||||
JPG("jpg", "image/jpg",false, true, false),
|
||||
ZIP("zip", "application/zip", false, false, false),
|
||||
WEBP("webp", "image/webp", false, true, false),
|
||||
HEIC("heic", "image/heic", false, true, false),
|
||||
HEIF("heif", "image/heif", false, true, false),
|
||||
MPD("mpd", "text/xml", false, false, false),
|
||||
UNKNOWN("dat", "application/octet-stream", false, false, false);
|
||||
|
||||
@@ -52,6 +54,44 @@ enum class FileType(
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun looksLikeIsoBmffVideo(array: ByteArray): Boolean {
|
||||
if (array.size < 12) return false
|
||||
// ISO BMFF containers like MP4 expose an `ftyp` box at byte offset 4.
|
||||
if (array[4] != 'f'.code.toByte() ||
|
||||
array[5] != 't'.code.toByte() ||
|
||||
array[6] != 'y'.code.toByte() ||
|
||||
array[7] != 'p'.code.toByte()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val majorBrand = String(array, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
|
||||
|
||||
// Explicitly exclude known IMAGE-only brands to prevent false positives (HEIC/HEIF)
|
||||
val imageBrands = setOf("heic", "heix", "hevc", "hevx", "mif1", "msf1")
|
||||
if (majorBrand in imageBrands) return false
|
||||
|
||||
return majorBrand in setOf(
|
||||
"mp41",
|
||||
"mp42",
|
||||
"isom",
|
||||
"iso2",
|
||||
"iso3",
|
||||
"iso4",
|
||||
"iso5",
|
||||
"iso6",
|
||||
"avc1",
|
||||
"dash",
|
||||
"cmfc",
|
||||
"msnv",
|
||||
"3gp4",
|
||||
"3gp5",
|
||||
"3gp6",
|
||||
"3g2a",
|
||||
"3g2b"
|
||||
) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video
|
||||
}
|
||||
|
||||
fun fromFile(file: File): FileType {
|
||||
file.inputStream().use { inputStream ->
|
||||
val buffer = ByteArray(16)
|
||||
@@ -64,7 +104,24 @@ enum class FileType(
|
||||
val headerBytes = ByteArray(16)
|
||||
System.arraycopy(array, 0, headerBytes, 0, 16)
|
||||
val hex = bytesToHex(headerBytes)
|
||||
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
|
||||
|
||||
// 1. Check strict signatures
|
||||
fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value?.let { return it }
|
||||
|
||||
// 2. Check ISO BMFF container type
|
||||
val majorBrand = if (headerBytes.size >= 12 &&
|
||||
headerBytes[4] == 'f'.code.toByte() && headerBytes[5] == 't'.code.toByte() &&
|
||||
headerBytes[6] == 'y'.code.toByte() && headerBytes[7] == 'p'.code.toByte()) {
|
||||
String(headerBytes, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
|
||||
} else null
|
||||
|
||||
if (majorBrand != null) {
|
||||
if (majorBrand in setOf("heic", "heix")) return HEIC
|
||||
if (majorBrand in setOf("mif1", "msf1")) return HEIF
|
||||
if (looksLikeIsoBmffVideo(headerBytes)) return MP4
|
||||
}
|
||||
|
||||
return UNKNOWN
|
||||
}
|
||||
|
||||
fun fromInputStream(inputStream: InputStream): FileType {
|
||||
|
||||
@@ -9,7 +9,9 @@ data class FriendPresenceState(
|
||||
val typing: Boolean,
|
||||
val wasTyping: Boolean,
|
||||
val speaking: Boolean,
|
||||
val peeking: Boolean
|
||||
val peeking: Boolean,
|
||||
val usingReplyCamera: Boolean,
|
||||
val viewingChatMedia: Boolean
|
||||
)
|
||||
|
||||
open class SessionEvent(
|
||||
@@ -44,6 +46,8 @@ enum class SessionEventType(
|
||||
SNAP_SCREENSHOT("snap_screenshot"),
|
||||
SNAP_SCREEN_RECORD("snap_screen_record"),
|
||||
I_CAN_SEE_YOU("i_can_see_you"),
|
||||
I_CAN_SEE_YOU_2("i_can_see_you_2"),
|
||||
I_CAN_SEE_YOU_3("i_can_see_you_3"),
|
||||
}
|
||||
|
||||
enum class TrackerEventType(
|
||||
@@ -58,6 +62,10 @@ enum class TrackerEventType(
|
||||
STOPPED_SPEAKING("stopped_speaking"),
|
||||
STARTED_PEEKING("started_peeking"),
|
||||
STOPPED_PEEKING("stopped_peeking"),
|
||||
STARTED_USING_REPLY_CAMERA("started_using_reply_camera"),
|
||||
STOPPED_USING_REPLY_CAMERA("stopped_using_reply_camera"),
|
||||
STARTED_VIEWING_CHAT_MEDIA("started_viewing_chat_media"),
|
||||
STOPPED_VIEWING_CHAT_MEDIA("stopped_viewing_chat_media"),
|
||||
|
||||
// mcs events
|
||||
MESSAGE_READ("message_read"),
|
||||
@@ -73,6 +81,8 @@ enum class TrackerEventType(
|
||||
SNAP_SCREENSHOT("snap_screenshot"),
|
||||
SNAP_SCREEN_RECORD("snap_screen_record"),
|
||||
I_CAN_SEE_YOU("i_can_see_you"),
|
||||
I_CAN_SEE_YOU_2("i_can_see_you_2"),
|
||||
I_CAN_SEE_YOU_3("i_can_see_you_3"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,21 +2,38 @@ package me.eternal.purrfectsnap.core
|
||||
|
||||
import android.system.Os
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.filled.Check
|
||||
import androidx.compose.material.icons.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.outlined.Cancel
|
||||
import androidx.compose.material.icons.rounded.NotInterested
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.common.bridge.FileHandleScope
|
||||
import me.eternal.purrfectsnap.common.bridge.toWrapper
|
||||
@@ -25,6 +42,8 @@ import me.eternal.purrfectsnap.common.config.VersionRequirement
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.ui.CustomComposable
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
@@ -48,6 +67,201 @@ class SecurityFeatures(
|
||||
transact(this, 0)?.toString(2)?.padStart(32, '0')?.count { it == '1' }
|
||||
}
|
||||
|
||||
private fun isLoginSignupActivity() = context.mainActivity?.javaClass?.name?.endsWith("LoginSignupActivity") == true
|
||||
|
||||
@Composable
|
||||
private fun LoginSignupHelpButton(onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 16.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.92f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.62f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(999.dp)
|
||||
)
|
||||
.border(
|
||||
BorderStroke(1.dp, PurrfectOverlayPalette.textPrimary.copy(alpha = 0.18f)),
|
||||
RoundedCornerShape(999.dp)
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.16f))
|
||||
.border(BorderStroke(1.dp, PurrfectOverlayPalette.textPrimary.copy(alpha = 0.28f)), CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.HelpOutline,
|
||||
contentDescription = "Login Help",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Can't Login?",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginSignupHelpDialog(onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
PurrfectOverlayTheme {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
shadowElevation = 0.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(PurrfectOverlayPalette.cardOverlay, shape)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Icon(
|
||||
Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = PurrfectOverlayPalette.textPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(28.dp)
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 10.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_intro"],
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "For non-rooted users:",
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_step_1"],
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_step_2"],
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_step_3"],
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_rooted_title"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.mappings.notice_rooted_body"],
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
val maxScroll = scrollState.maxValue
|
||||
val thumbRatio = if (maxScroll > 0) {
|
||||
((scrollState.value.toFloat() / maxScroll.toFloat()) * 0.7f).coerceIn(0f, 0.7f)
|
||||
} else 0f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(999.dp))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(if (maxScroll > 0) 0.28f else 1f)
|
||||
.offset(y = (320.dp * thumbRatio))
|
||||
.background(
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.75f),
|
||||
RoundedCornerShape(999.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.92f),
|
||||
contentColor = Color.Black
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
val snapchatVersionCode = context.androidContext.packageManager?.getPackageInfo(context.androidContext.packageName, 0)?.longVersionCode ?: throw IllegalStateException("Failed to get version code")
|
||||
var shouldDisablePlugin = MOD_DETECTION_VERSION_CHECK.checkVersion(snapchatVersionCode)?.second == VersionRequirement.OLDER_REQUIRED
|
||||
@@ -101,6 +315,35 @@ class SecurityFeatures(
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var loginHelpComposable: CustomComposable
|
||||
loginHelpComposable = {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var isLoginScreen by remember { mutableStateOf(false) }
|
||||
val disableHelpButton = context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true"
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val currentlyInLogin = isLoginSignupActivity()
|
||||
isLoginScreen = currentlyInLogin
|
||||
if (!currentlyInLogin) showDialog = false
|
||||
delay(150)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoginScreen && !disableHelpButton) {
|
||||
LoginSignupHelpButton(
|
||||
onClick = { showDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoginScreen && !disableHelpButton && showDialog) {
|
||||
LoginSignupHelpDialog(
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
context.inAppOverlay.addCustomComposable(loginHelpComposable)
|
||||
|
||||
if (!context.disablePlugin) return
|
||||
|
||||
val allowedEPs = listOf(
|
||||
@@ -191,6 +434,9 @@ class SecurityFeatures(
|
||||
|
||||
context.features.addActivityCreateListener { activity ->
|
||||
if (!activity.javaClass.name.endsWith("LoginSignupActivity")) return@addActivityCreateListener
|
||||
if (context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true") {
|
||||
return@addActivityCreateListener
|
||||
}
|
||||
|
||||
activity.findViewById<ViewGroup>(android.R.id.content).apply {
|
||||
visibility = ViewGroup.INVISIBLE
|
||||
|
||||
@@ -113,6 +113,18 @@ class BulkMessagingAction : AbstractAction() {
|
||||
private val translation by lazy { context.translation.getCategory("bulk_messaging_action") }
|
||||
private val betterLocation by lazy { context.feature(BetterLocation::class) }
|
||||
|
||||
private fun hasReliableStreak(friend: FriendInfo, streakFeedUserIds: Set<String>): Boolean {
|
||||
val userId = friend.userId ?: return false
|
||||
if (userId in streakFeedUserIds) return true
|
||||
if (friend.streakExpirationTimestamp > 0L) return true
|
||||
if (friend.streakLength > 0) return true
|
||||
val categories = friend.friendmojiCategories?.split(",") ?: return false
|
||||
return categories.any { category ->
|
||||
category.contains("streak", ignoreCase = true) ||
|
||||
category.contains("hourglass", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private object BulkMessagingPalette {
|
||||
val background = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -286,7 +298,12 @@ class BulkMessagingAction : AbstractAction() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
|
||||
private fun filterFriends(
|
||||
friends: List<FriendInfo>,
|
||||
filter: Filter,
|
||||
nameFilter: String,
|
||||
streakFeedUserIds: Set<String> = emptySet()
|
||||
): List<FriendInfo> {
|
||||
val userIdBlacklist = arrayOf(
|
||||
context.database.myUserId,
|
||||
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai
|
||||
@@ -310,8 +327,12 @@ class BulkMessagingAction : AbstractAction() {
|
||||
Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value
|
||||
Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value
|
||||
Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0
|
||||
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0
|
||||
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0
|
||||
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
|
||||
friend.addedTimestamp > 0 &&
|
||||
hasReliableStreak(friend, streakFeedUserIds)
|
||||
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
|
||||
friend.addedTimestamp > 0 &&
|
||||
!hasReliableStreak(friend, streakFeedUserIds)
|
||||
Filter.FOLLOWING -> {
|
||||
val isFollowing = friend.friendLinkType == FriendLinkType.FOLLOWING.value ||
|
||||
(friend.friendLinkType == FriendLinkType.OUTGOING.value &&
|
||||
@@ -390,10 +411,21 @@ class BulkMessagingAction : AbstractAction() {
|
||||
val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) {
|
||||
runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() }
|
||||
} else emptySet()
|
||||
val streakFeedUserIds = if (filter == Filter.STREAKS || filter == Filter.NON_STREAKS) {
|
||||
runCatching {
|
||||
context.database.getFeedEntries(Int.MAX_VALUE)
|
||||
.filter { it.conversationType == 0 && it.participantsSize == 2 }
|
||||
.filter { (it.streakCount ?: 0) > 0 || (it.streakExpirationTimestampMs ?: 0L) > 0L }
|
||||
.mapNotNull { entry ->
|
||||
entry.friendUserId ?: entry.participants?.firstOrNull { id -> id != context.database.myUserId }
|
||||
}
|
||||
.toSet()
|
||||
}.getOrElse { emptySet() }
|
||||
} else emptySet()
|
||||
|
||||
val newFriends = if (conversationType == ConversationType.FRIENDS_ONLY || conversationType == ConversationType.BOTH) {
|
||||
context.database.getAllFriends().let { friends ->
|
||||
filterFriends(friends, filter, nameFilter)
|
||||
filterFriends(friends, filter, nameFilter, streakFeedUserIds)
|
||||
}
|
||||
.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }
|
||||
.filter { friend ->
|
||||
|
||||
@@ -30,6 +30,7 @@ import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.OverlayType
|
||||
import me.eternal.purrfectsnap.common.util.toSerialized
|
||||
import me.eternal.purrfectsnap.core.ModContext
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
@@ -234,10 +235,48 @@ class BridgeClient(
|
||||
|
||||
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
|
||||
safeServiceCall {
|
||||
service.passGroupsAndFriends(
|
||||
groups.mapNotNull { it.toSerialized() },
|
||||
friends.mapNotNull { it.toSerialized() }
|
||||
val serializedGroups = groups.mapNotNull { it.toSerialized() }
|
||||
val serializedFriends = friends.mapNotNull { it.toSerialized() }
|
||||
val maxChunkBytes = 128 * 1024
|
||||
|
||||
fun chunkSerialized(values: List<String>): List<List<String>> {
|
||||
if (values.isEmpty()) return listOf(emptyList())
|
||||
val result = mutableListOf<List<String>>()
|
||||
val currentChunk = mutableListOf<String>()
|
||||
var currentSize = 0
|
||||
|
||||
values.forEach { value ->
|
||||
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
|
||||
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
|
||||
result += currentChunk.toList()
|
||||
currentChunk.clear()
|
||||
currentSize = 0
|
||||
}
|
||||
currentChunk += value
|
||||
currentSize += valueSize
|
||||
}
|
||||
|
||||
if (currentChunk.isNotEmpty()) {
|
||||
result += currentChunk.toList()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val groupChunks = chunkSerialized(serializedGroups)
|
||||
val friendChunks = chunkSerialized(serializedFriends)
|
||||
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
|
||||
|
||||
context.log.info(
|
||||
"Sending social snapshot in $chunkCount chunk(s): " +
|
||||
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
|
||||
)
|
||||
|
||||
repeat(chunkCount) { index ->
|
||||
service.passGroupsAndFriends(
|
||||
groupChunks.getOrElse(index) { emptyList() },
|
||||
friendChunks.getOrElse(index) { emptyList() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {
|
||||
|
||||
@@ -34,6 +34,8 @@ abstract class Feature(
|
||||
|
||||
open fun init() {}
|
||||
|
||||
open fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {}
|
||||
|
||||
|
||||
protected fun findClass(name: String): Class<*> {
|
||||
return context.androidContext.classLoader.loadClass(name)
|
||||
|
||||
@@ -114,6 +114,10 @@ class FeatureManager(
|
||||
HideFriendFeedEntry(),
|
||||
RequerySqlite(),
|
||||
RefreshFriendSuggestions(),
|
||||
LocalPinnedMessages(),
|
||||
BlockCalls(),
|
||||
CallMetadataNotifier(),
|
||||
ConversationSoundEffects(),
|
||||
CallButtonsOverride(),
|
||||
SnapPreview(),
|
||||
BypassScreenshotDetection(),
|
||||
|
||||
@@ -36,4 +36,20 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
override fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {
|
||||
if (action == "get_state") {
|
||||
val conversationId = extras?.get("conversationId") as? String ?: return
|
||||
callback(getState(conversationId))
|
||||
return
|
||||
}
|
||||
if (action == "set_state") {
|
||||
val conversationId = extras?.get("conversationId") as? String ?: return
|
||||
val state = extras["state"] as? Boolean ?: return
|
||||
setState(conversationId, state)
|
||||
callback(true)
|
||||
return
|
||||
}
|
||||
super.onBridgeAction(action, extras, callback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.ImageView
|
||||
@@ -13,9 +14,11 @@ import android.widget.TextView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -26,17 +29,22 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
@@ -84,8 +92,12 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
|
||||
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.nio.file.Paths
|
||||
import java.util.UUID
|
||||
import java.util.Collections
|
||||
import java.util.IdentityHashMap
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.absoluteValue
|
||||
import android.util.Base64
|
||||
@@ -107,10 +119,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
|
||||
var lastSeenMapParams: ParamMap? = null
|
||||
private set
|
||||
@Volatile
|
||||
private var pendingBatchDownloadIndices: MutableList<Int>? = null
|
||||
@Volatile
|
||||
private var batchForceAllowDuplicate: Boolean = false
|
||||
private val translations by lazy {
|
||||
context.translation.getCategory("download_processor")
|
||||
}
|
||||
@@ -248,16 +256,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
|
||||
val cancelStr = context.translation["button.cancel"]
|
||||
val downloadStr = context.translation["button.download"]
|
||||
|
||||
context.runOnUiThread {
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!selected.contains(currentIndex)) selected.add(currentIndex)
|
||||
}
|
||||
|
||||
PurrfectGlassCard(
|
||||
title = tr["title"],
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -280,7 +283,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selected.contains(index),
|
||||
@@ -333,10 +337,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (selected.isNotEmpty()) {
|
||||
startBatchDownload(selected.sorted().toMutableList(), allowDuplicate)
|
||||
alertDialog.dismiss()
|
||||
if (!selected.contains(currentIndex)) return@Button
|
||||
context.executeAsync {
|
||||
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) }
|
||||
.onFailure {
|
||||
context.log.error("Story download failed", it)
|
||||
context.shortToast(translations["failed_generic_toast"])
|
||||
}
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -355,81 +364,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
}
|
||||
|
||||
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
|
||||
if (indices.isEmpty()) return
|
||||
val paramMap = lastSeenMapParams ?: return
|
||||
val mediaInfoMap = lastSeenMediaInfoMap ?: return
|
||||
|
||||
pendingBatchDownloadIndices = indices
|
||||
batchForceAllowDuplicate = allowDuplicate
|
||||
|
||||
val currentIndex = paramMap.getStorySnapIndex() ?: 0
|
||||
val targetIndex = indices.first()
|
||||
val totalCount = paramMap.getStorySnapTotal()
|
||||
|
||||
if (currentIndex == targetIndex) {
|
||||
processNextBatchDownload(paramMap, mediaInfoMap)
|
||||
} else {
|
||||
val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount)
|
||||
if (!jumped) {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_jump_failed_toast"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
|
||||
context.executeAsync {
|
||||
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) }
|
||||
.onFailure {
|
||||
context.log.error("Batch download failed", it)
|
||||
context.shortToast(translations["failed_generic_toast"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
|
||||
val queue = pendingBatchDownloadIndices ?: return
|
||||
if (queue.isEmpty()) {
|
||||
flushPendingMergeAndComplete()
|
||||
return
|
||||
}
|
||||
|
||||
val currentIndex = paramMap.getStorySnapIndex() ?: -1
|
||||
if (currentIndex != queue.first()) return
|
||||
|
||||
queue.removeAt(0)
|
||||
downloadSingleSnap(paramMap, mediaInfoMap)
|
||||
|
||||
if (queue.isEmpty()) {
|
||||
flushPendingMergeAndComplete()
|
||||
} else {
|
||||
val totalCount = paramMap.getStorySnapTotal()
|
||||
context.runOnUiThread {
|
||||
fun tryJump(retryCount: Int = 0) {
|
||||
val delayMs = if (retryCount == 0) 120L else 220L
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
val jumped = runCatching {
|
||||
context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount)
|
||||
}.getOrNull() == true
|
||||
if (!jumped && retryCount < 1) {
|
||||
tryJump(retryCount + 1)
|
||||
} else if (!jumped) {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_jump_failed_toast"])
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
tryJump()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushPendingMergeAndComplete() {
|
||||
pendingBatchDownloadIndices = null
|
||||
context.shortToast(translations["batch_download_complete_toast"])
|
||||
}
|
||||
|
||||
fun showLastOperaDebugMediaInfo() {
|
||||
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
|
||||
|
||||
@@ -918,15 +852,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
lastSeenMapParams = mediaParamMap
|
||||
lastSeenMediaInfoMap = mediaInfoMap
|
||||
|
||||
if (pendingBatchDownloadIndices != null) {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
if (pendingBatchDownloadIndices != null) {
|
||||
processNextBatchDownload(mediaParamMap, mediaInfoMap)
|
||||
}
|
||||
}, 80L)
|
||||
return@onOperaViewStateCallback
|
||||
}
|
||||
|
||||
if (!shouldAutoDownload) {
|
||||
return@onOperaViewStateCallback
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,193 +15,358 @@ data class DeviceInfo(
|
||||
val host: String
|
||||
)
|
||||
|
||||
data class DeviceBuildProfile(
|
||||
val androidRelease: String,
|
||||
val display: String,
|
||||
val buildId: String,
|
||||
val incremental: String,
|
||||
val host: String,
|
||||
val bootloader: String? = null
|
||||
)
|
||||
|
||||
data class DeviceCapabilityProfile(
|
||||
val supportedAbis: List<String>,
|
||||
val supported32BitAbis: List<String>,
|
||||
val supported64BitAbis: List<String>,
|
||||
val phoneCount: Int,
|
||||
val isHearingAidCompatibilitySupported: Boolean,
|
||||
val isTtySupported: Boolean,
|
||||
val isWorldPhone: Boolean,
|
||||
val isSmsCapable: Boolean,
|
||||
val isVoiceCapable: Boolean,
|
||||
val phoneType: Int,
|
||||
val phoneTypeString: String
|
||||
)
|
||||
|
||||
data class DeviceTemplate(
|
||||
val marketingName: String,
|
||||
val deviceInfo: DeviceInfo,
|
||||
val builds: List<DeviceBuildProfile>,
|
||||
val capabilities: DeviceCapabilityProfile
|
||||
)
|
||||
|
||||
object DeviceSpoofer {
|
||||
private val defaultCapabilities = DeviceCapabilityProfile(
|
||||
supportedAbis = listOf("arm64-v8a", "armeabi-v7a", "armeabi"),
|
||||
supported32BitAbis = listOf("armeabi-v7a", "armeabi"),
|
||||
supported64BitAbis = listOf("arm64-v8a"),
|
||||
phoneCount = 2,
|
||||
isHearingAidCompatibilitySupported = true,
|
||||
isTtySupported = false,
|
||||
isWorldPhone = true,
|
||||
isSmsCapable = true,
|
||||
isVoiceCapable = true,
|
||||
phoneType = 1,
|
||||
phoneTypeString = "PHONE_TYPE_GSM"
|
||||
)
|
||||
|
||||
private val singleSimCapabilities = defaultCapabilities.copy(phoneCount = 1)
|
||||
|
||||
private val devices = mapOf(
|
||||
"Pixel 8 Pro" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 8 Pro",
|
||||
brand = "google",
|
||||
device = "husky",
|
||||
product = "husky",
|
||||
hardware = "husky",
|
||||
board = "husky",
|
||||
bootloader = "husky-1.0-11003666",
|
||||
display = "UQ1A.231205.015",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
"Pixel 8 Pro" to DeviceTemplate(
|
||||
marketingName = "Pixel 8 Pro",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 8 Pro",
|
||||
brand = "google",
|
||||
device = "husky",
|
||||
product = "husky",
|
||||
hardware = "husky",
|
||||
board = "husky",
|
||||
bootloader = "husky-1.0-11003666",
|
||||
display = "UQ1A.231205.015",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "UQ1A.231205.015", "UQ1A.231205.015", "11003666", "abfarm-release-rbe-64-00163", "husky-1.0-11003666"),
|
||||
DeviceBuildProfile("15", "AP4A.250205.002", "AP4A.250205.002", "12141234", "abfarm-release-rbe-64-00171", "husky-1.0-12141234")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
|
||||
),
|
||||
"Pixel 9 Pro XL" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 9 Pro XL",
|
||||
brand = "google",
|
||||
device = "komodo",
|
||||
product = "komodo",
|
||||
hardware = "komodo",
|
||||
board = "komodo",
|
||||
bootloader = "komodo-1.0-12110753",
|
||||
display = "AP3A.241105.008",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
"Pixel 9 Pro XL" to DeviceTemplate(
|
||||
marketingName = "Pixel 9 Pro XL",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 9 Pro XL",
|
||||
brand = "google",
|
||||
device = "komodo",
|
||||
product = "komodo",
|
||||
hardware = "komodo",
|
||||
board = "komodo",
|
||||
bootloader = "komodo-1.0-12110753",
|
||||
display = "AP3A.241105.008",
|
||||
host = "abfarm-release-rbe-64-00163"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "AP3A.241105.008", "AP3A.241105.008", "12110753", "abfarm-release-rbe-64-00163", "komodo-1.0-12110753"),
|
||||
DeviceBuildProfile("15", "BP1A.250105.006", "BP1A.250105.006", "13120567", "abfarm-release-rbe-65-00088", "komodo-1.0-13120567")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
|
||||
),
|
||||
"Pixel 10" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10",
|
||||
brand = "google",
|
||||
device = "frankel",
|
||||
product = "frankel",
|
||||
hardware = "tensor_g5",
|
||||
board = "frankel",
|
||||
bootloader = "frankel-1.0-12345678",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00200"
|
||||
"Pixel 10" to DeviceTemplate(
|
||||
marketingName = "Pixel 10",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10",
|
||||
brand = "google",
|
||||
device = "frankel",
|
||||
product = "frankel",
|
||||
hardware = "tensor_g5",
|
||||
board = "frankel",
|
||||
bootloader = "frankel-1.0-12345678",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00200"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345678", "abfarm-release-rbe-65-00200", "frankel-1.0-12345678")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
|
||||
),
|
||||
"Pixel 10 Pro" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro",
|
||||
brand = "google",
|
||||
device = "blazer",
|
||||
product = "blazer",
|
||||
hardware = "tensor_g5",
|
||||
board = "blazer",
|
||||
bootloader = "blazer-1.0-12345679",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00201"
|
||||
"Pixel 10 Pro" to DeviceTemplate(
|
||||
marketingName = "Pixel 10 Pro",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro",
|
||||
brand = "google",
|
||||
device = "blazer",
|
||||
product = "blazer",
|
||||
hardware = "tensor_g5",
|
||||
board = "blazer",
|
||||
bootloader = "blazer-1.0-12345679",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00201"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345679", "abfarm-release-rbe-65-00201", "blazer-1.0-12345679")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
|
||||
),
|
||||
"Pixel 10 Pro XL" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro XL",
|
||||
brand = "google",
|
||||
device = "mustang",
|
||||
product = "mustang",
|
||||
hardware = "tensor_g5",
|
||||
board = "mustang",
|
||||
bootloader = "mustang-1.0-12345680",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00202"
|
||||
"Pixel 10 Pro XL" to DeviceTemplate(
|
||||
marketingName = "Pixel 10 Pro XL",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro XL",
|
||||
brand = "google",
|
||||
device = "mustang",
|
||||
product = "mustang",
|
||||
hardware = "tensor_g5",
|
||||
board = "mustang",
|
||||
bootloader = "mustang-1.0-12345680",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00202"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345680", "abfarm-release-rbe-65-00202", "mustang-1.0-12345680")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
|
||||
),
|
||||
"Pixel 10 Pro Fold" to DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro Fold",
|
||||
brand = "google",
|
||||
device = "rango",
|
||||
product = "rango",
|
||||
hardware = "tensor_g5",
|
||||
board = "rango",
|
||||
bootloader = "rango-1.0-12345681",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00203"
|
||||
"Pixel 10 Pro Fold" to DeviceTemplate(
|
||||
marketingName = "Pixel 10 Pro Fold",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Google",
|
||||
model = "Pixel 10 Pro Fold",
|
||||
brand = "google",
|
||||
device = "rango",
|
||||
product = "rango",
|
||||
hardware = "tensor_g5",
|
||||
board = "rango",
|
||||
bootloader = "rango-1.0-12345681",
|
||||
display = "BP1A.250105.002",
|
||||
host = "abfarm-release-rbe-65-00203"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345681", "abfarm-release-rbe-65-00203", "rango-1.0-12345681")
|
||||
),
|
||||
capabilities = singleSimCapabilities.copy(isWorldPhone = false)
|
||||
),
|
||||
"Galaxy S23 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S918B",
|
||||
brand = "samsung",
|
||||
device = "dm3q",
|
||||
product = "dm3qxx",
|
||||
hardware = "qcom",
|
||||
board = "kalama",
|
||||
bootloader = "S918BXXU3BWJM",
|
||||
display = "UP1A.231005.007.S918BXXU3BWJM",
|
||||
host = "21DH7R2P"
|
||||
"Galaxy S23 Ultra" to DeviceTemplate(
|
||||
marketingName = "Galaxy S23 Ultra",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S918B",
|
||||
brand = "samsung",
|
||||
device = "dm3q",
|
||||
product = "dm3qxx",
|
||||
hardware = "qcom",
|
||||
board = "kalama",
|
||||
bootloader = "S918BXXU3BWJM",
|
||||
display = "UP1A.231005.007.S918BXXU3BWJM",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "UP1A.231005.007.S918BXXU3BWJM", "UP1A.231005.007", "S918BXXU3BWJM", "21DH7R2P", "S918BXXU3BWJM"),
|
||||
DeviceBuildProfile("15", "AP3A.240905.015.S918BXXU4CXA1", "AP3A.240905.015", "S918BXXU4CXA1", "21DH7R2P", "S918BXXU4CXA1")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"Galaxy S24 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S928B",
|
||||
brand = "samsung",
|
||||
device = "e9q",
|
||||
product = "e9qxx",
|
||||
hardware = "qcom",
|
||||
board = "pineapple",
|
||||
bootloader = "S928BXXU1AXB5",
|
||||
display = "UP1A.231005.007.S928BXXU1AXB5",
|
||||
host = "21DH7R2P"
|
||||
"Galaxy S24 Ultra" to DeviceTemplate(
|
||||
marketingName = "Galaxy S24 Ultra",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S928B",
|
||||
brand = "samsung",
|
||||
device = "e9q",
|
||||
product = "e9qxx",
|
||||
hardware = "qcom",
|
||||
board = "pineapple",
|
||||
bootloader = "S928BXXU1AXB5",
|
||||
display = "UP1A.231005.007.S928BXXU1AXB5",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "UP1A.231005.007.S928BXXU1AXB5", "UP1A.231005.007", "S928BXXU1AXB5", "21DH7R2P", "S928BXXU1AXB5"),
|
||||
DeviceBuildProfile("15", "AP3A.240905.015.S928BXXU2BYD6", "AP3A.240905.015", "S928BXXU2BYD6", "21DH7R2P", "S928BXXU2BYD6")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"Galaxy S25 Ultra" to DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S938B",
|
||||
brand = "samsung",
|
||||
device = "e3q",
|
||||
product = "e3qxx",
|
||||
hardware = "qcom",
|
||||
board = "s5e9945",
|
||||
bootloader = "S938BXXU1AXL2",
|
||||
display = "UP1A.231005.007.S938BXXU1AXL2",
|
||||
host = "21DH7R2P"
|
||||
"Galaxy S25 Ultra" to DeviceTemplate(
|
||||
marketingName = "Galaxy S25 Ultra",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Samsung",
|
||||
model = "SM-S938B",
|
||||
brand = "samsung",
|
||||
device = "e3q",
|
||||
product = "e3qxx",
|
||||
hardware = "qcom",
|
||||
board = "s5e9945",
|
||||
bootloader = "S938BXXU1AXL2",
|
||||
display = "UP1A.231005.007.S938BXXU1AXL2",
|
||||
host = "21DH7R2P"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "AP3A.241005.019.S938BXXU1AXL2", "AP3A.241005.019", "S938BXXU1AXL2", "21DH7R2P", "S938BXXU1AXL2")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"OnePlus 15" to DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2651",
|
||||
brand = "OnePlus",
|
||||
device = "OP5929L1",
|
||||
product = "OP5929L1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2651_15.0.0.503(EX01)",
|
||||
host = "ubuntu-build"
|
||||
"OnePlus 15" to DeviceTemplate(
|
||||
marketingName = "OnePlus 15",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2651",
|
||||
brand = "OnePlus",
|
||||
device = "OP5929L1",
|
||||
product = "OP5929L1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2651_15.0.0.503(EX01)",
|
||||
host = "ubuntu-build"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "CPH2651_15.0.0.503(EX01)", "CPH2651_15.0.0.503(EX01)", "15.0.0.503", "ubuntu-build"),
|
||||
DeviceBuildProfile("15", "CPH2651_15.0.0.601(EX01)", "CPH2651_15.0.0.601(EX01)", "15.0.0.601", "ubuntu-build")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"OnePlus Open" to DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2551",
|
||||
brand = "OnePlus",
|
||||
device = "OP594DL1",
|
||||
product = "OP594DL1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2551_14.0.0.600(EX01)",
|
||||
host = "ubuntu-build"
|
||||
"OnePlus Open" to DeviceTemplate(
|
||||
marketingName = "OnePlus Open",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "OnePlus",
|
||||
model = "CPH2551",
|
||||
brand = "OnePlus",
|
||||
device = "OP594DL1",
|
||||
product = "OP594DL1_EEA",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "CPH2551_14.0.0.600(EX01)",
|
||||
host = "ubuntu-build"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "CPH2551_14.0.0.600(EX01)", "CPH2551_14.0.0.600(EX01)", "14.0.0.600", "ubuntu-build"),
|
||||
DeviceBuildProfile("15", "CPH2551_15.0.0.305(EX01)", "CPH2551_15.0.0.305(EX01)", "15.0.0.305", "ubuntu-build")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"Xiaomi 15 Ultra" to DeviceInfo(
|
||||
manufacturer = "Xiaomi",
|
||||
model = "25010PN30G",
|
||||
brand = "Xiaomi",
|
||||
device = "xuanyuan",
|
||||
product = "xuanyuan_global",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "VK.15.0.3.0.VNGMIXM",
|
||||
host = "c3-miui-ota-bd164.bj"
|
||||
"Xiaomi 15 Ultra" to DeviceTemplate(
|
||||
marketingName = "Xiaomi 15 Ultra",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "Xiaomi",
|
||||
model = "25010PN30G",
|
||||
brand = "Xiaomi",
|
||||
device = "xuanyuan",
|
||||
product = "xuanyuan_global",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "VK.15.0.3.0.VNGMIXM",
|
||||
host = "c3-miui-ota-bd164.bj"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "VK.15.0.3.0.VNGMIXM", "VK.15.0.3.0.VNGMIXM", "15.0.3.0", "c3-miui-ota-bd164.bj"),
|
||||
DeviceBuildProfile("15", "VK.15.0.6.0.VNGMIXM", "VK.15.0.6.0.VNGMIXM", "15.0.6.0", "c3-miui-ota-bd164.bj")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"OPPO Find X9 Pro" to DeviceInfo(
|
||||
manufacturer = "OPPO",
|
||||
model = "PHY110",
|
||||
brand = "OPPO",
|
||||
device = "OP595DL1",
|
||||
product = "OP595DL1_EEA",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "PHY110_15.0.0.100(EX01)",
|
||||
host = "ubuntu-build-server"
|
||||
"OPPO Find X9 Pro" to DeviceTemplate(
|
||||
marketingName = "OPPO Find X9 Pro",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "OPPO",
|
||||
model = "PHY110",
|
||||
brand = "OPPO",
|
||||
device = "OP595DL1",
|
||||
product = "OP595DL1_EEA",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "PHY110_15.0.0.100(EX01)",
|
||||
host = "ubuntu-build-server"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("15", "PHY110_15.0.0.100(EX01)", "PHY110_15.0.0.100(EX01)", "15.0.0.100", "ubuntu-build-server"),
|
||||
DeviceBuildProfile("15", "PHY110_15.0.0.202(EX01)", "PHY110_15.0.0.202(EX01)", "15.0.0.202", "ubuntu-build-server")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"vivo X100 Pro" to DeviceInfo(
|
||||
manufacturer = "vivo",
|
||||
model = "V2309A",
|
||||
brand = "vivo",
|
||||
device = "V2309A",
|
||||
product = "PD2309",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "OP557L.PD2309.14.0.0.100",
|
||||
host = "compiler-server"
|
||||
"vivo X100 Pro" to DeviceTemplate(
|
||||
marketingName = "vivo X100 Pro",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "vivo",
|
||||
model = "V2309A",
|
||||
brand = "vivo",
|
||||
device = "V2309A",
|
||||
product = "PD2309",
|
||||
hardware = "mt6989",
|
||||
board = "k6989v1_64",
|
||||
bootloader = "unknown",
|
||||
display = "PD2309F_EX_A_14.0.13.2.W30",
|
||||
host = "compiler-server"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "PD2309F_EX_A_14.0.13.2.W30", "PD2309F_EX_A_14.0.13.2.W30", "14.0.13.2", "compiler-server"),
|
||||
DeviceBuildProfile("15", "PD2309F_EX_A_15.0.8.5.W30", "PD2309F_EX_A_15.0.8.5.W30", "15.0.8.5", "compiler-server")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
),
|
||||
"realme GT 6" to DeviceInfo(
|
||||
manufacturer = "realme",
|
||||
model = "RMX3851",
|
||||
brand = "realme",
|
||||
device = "RMX3851",
|
||||
product = "RMX3851_11_A.13",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "RMX3851_14.0.0.700(EX01)",
|
||||
host = "ubuntu-server"
|
||||
"realme GT 6" to DeviceTemplate(
|
||||
marketingName = "realme GT 6",
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = "realme",
|
||||
model = "RMX3851",
|
||||
brand = "realme",
|
||||
device = "RMX3851",
|
||||
product = "RMX3851_11_A.13",
|
||||
hardware = "qcom",
|
||||
board = "taro",
|
||||
bootloader = "unknown",
|
||||
display = "RMX3851_14.0.0.700(EX01)",
|
||||
host = "ubuntu-server"
|
||||
),
|
||||
builds = listOf(
|
||||
DeviceBuildProfile("14", "RMX3851_14.0.0.700(EX01)", "RMX3851_14.0.0.700(EX01)", "14.0.0.700", "ubuntu-server"),
|
||||
DeviceBuildProfile("15", "RMX3851_15.0.0.205(EX01)", "RMX3851_15.0.0.205(EX01)", "15.0.0.205", "ubuntu-server")
|
||||
),
|
||||
capabilities = defaultCapabilities.copy(phoneCount = 2)
|
||||
)
|
||||
)
|
||||
|
||||
fun getAvailableDevices(): List<String> = devices.keys.toList()
|
||||
|
||||
fun getDeviceInfo(modelName: String): DeviceInfo? {
|
||||
return devices[modelName]?.deviceInfo
|
||||
}
|
||||
|
||||
fun getDeviceTemplate(modelName: String): DeviceTemplate? {
|
||||
return devices[modelName]
|
||||
}
|
||||
|
||||
@@ -211,6 +376,10 @@ object DeviceSpoofer {
|
||||
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys"
|
||||
}
|
||||
|
||||
fun generateFingerprint(deviceInfo: DeviceInfo, buildProfile: DeviceBuildProfile): String {
|
||||
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:${buildProfile.androidRelease}/${buildProfile.buildId}/${buildProfile.incremental}:user/release-keys"
|
||||
}
|
||||
|
||||
fun generateAndroidId(): String {
|
||||
val random = SecureRandom()
|
||||
val bytes = ByteArray(8)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,6 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.content.Context
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.InetAddress
|
||||
import java.security.SecureRandom
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
data class RandomizedDeviceProfile(
|
||||
val schemaVersion: Int,
|
||||
val profileId: String,
|
||||
val deviceInfo: DeviceInfo,
|
||||
val androidRelease: String,
|
||||
val buildIncremental: String,
|
||||
val buildDisplayId: String,
|
||||
val buildFingerprint: String,
|
||||
val buildHost: String,
|
||||
val buildTime: Long,
|
||||
val supportedAbis: List<String>,
|
||||
val supported32BitAbis: List<String>,
|
||||
val supported64BitAbis: List<String>,
|
||||
val androidId: String,
|
||||
val gsfId: String,
|
||||
val advertisingId: String,
|
||||
val wifiMacAddress: String,
|
||||
val bluetoothMacAddress: String,
|
||||
val ipAddress: String,
|
||||
val wifiSsid: String,
|
||||
val wifiRssi: Int,
|
||||
val localeTag: String,
|
||||
val countryIso: String,
|
||||
val timeZoneId: String,
|
||||
val timeZoneDisplayName: String,
|
||||
val networkType: Int,
|
||||
val networkOperator: String,
|
||||
val networkOperatorName: String,
|
||||
val networkCountryIso: String,
|
||||
val simCountryIso: String,
|
||||
val simOperator: String,
|
||||
val simOperatorName: String,
|
||||
val simState: Int,
|
||||
val hasIccCard: Boolean,
|
||||
val phoneCount: Int,
|
||||
val isHearingAidCompatibilitySupported: Boolean,
|
||||
val isTtySupported: Boolean,
|
||||
val isWorldPhone: Boolean,
|
||||
val isNetworkRoaming: Boolean,
|
||||
val isSmsCapable: Boolean,
|
||||
val isVoiceCapable: Boolean,
|
||||
val phoneType: Int,
|
||||
val phoneTypeString: String,
|
||||
val mmsUaProfUrl: String,
|
||||
val mmsUserAgent: String,
|
||||
val dnsServers: List<String>,
|
||||
val dnsSearchDomains: String,
|
||||
val privateDnsServerName: String,
|
||||
val privateDnsActive: Boolean,
|
||||
val hasCaptivePortal: Boolean,
|
||||
val secureStringSettings: Map<String, String>,
|
||||
val secureIntSettings: Map<String, Int>,
|
||||
val systemStringSettings: Map<String, String>,
|
||||
val systemIntSettings: Map<String, Int>,
|
||||
val globalStringSettings: Map<String, String>,
|
||||
val globalIntSettings: Map<String, Int>
|
||||
) {
|
||||
fun locale(): Locale = Locale.forLanguageTag(localeTag)
|
||||
|
||||
fun timeZone(): TimeZone = TimeZone.getTimeZone(timeZoneId)
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("schemaVersion", schemaVersion)
|
||||
put("profileId", profileId)
|
||||
put("deviceInfo", JSONObject().apply {
|
||||
put("manufacturer", deviceInfo.manufacturer)
|
||||
put("model", deviceInfo.model)
|
||||
put("brand", deviceInfo.brand)
|
||||
put("device", deviceInfo.device)
|
||||
put("product", deviceInfo.product)
|
||||
put("hardware", deviceInfo.hardware)
|
||||
put("board", deviceInfo.board)
|
||||
put("bootloader", deviceInfo.bootloader)
|
||||
put("display", deviceInfo.display)
|
||||
put("host", deviceInfo.host)
|
||||
})
|
||||
put("androidRelease", androidRelease)
|
||||
put("buildIncremental", buildIncremental)
|
||||
put("buildDisplayId", buildDisplayId)
|
||||
put("buildFingerprint", buildFingerprint)
|
||||
put("buildHost", buildHost)
|
||||
put("buildTime", buildTime)
|
||||
put("supportedAbis", JSONArray(supportedAbis))
|
||||
put("supported32BitAbis", JSONArray(supported32BitAbis))
|
||||
put("supported64BitAbis", JSONArray(supported64BitAbis))
|
||||
put("androidId", androidId)
|
||||
put("gsfId", gsfId)
|
||||
put("advertisingId", advertisingId)
|
||||
put("wifiMacAddress", wifiMacAddress)
|
||||
put("bluetoothMacAddress", bluetoothMacAddress)
|
||||
put("ipAddress", ipAddress)
|
||||
put("wifiSsid", wifiSsid)
|
||||
put("wifiRssi", wifiRssi)
|
||||
put("localeTag", localeTag)
|
||||
put("countryIso", countryIso)
|
||||
put("timeZoneId", timeZoneId)
|
||||
put("timeZoneDisplayName", timeZoneDisplayName)
|
||||
put("networkType", networkType)
|
||||
put("networkOperator", networkOperator)
|
||||
put("networkOperatorName", networkOperatorName)
|
||||
put("networkCountryIso", networkCountryIso)
|
||||
put("simCountryIso", simCountryIso)
|
||||
put("simOperator", simOperator)
|
||||
put("simOperatorName", simOperatorName)
|
||||
put("simState", simState)
|
||||
put("hasIccCard", hasIccCard)
|
||||
put("phoneCount", phoneCount)
|
||||
put("isHearingAidCompatibilitySupported", isHearingAidCompatibilitySupported)
|
||||
put("isTtySupported", isTtySupported)
|
||||
put("isWorldPhone", isWorldPhone)
|
||||
put("isNetworkRoaming", isNetworkRoaming)
|
||||
put("isSmsCapable", isSmsCapable)
|
||||
put("isVoiceCapable", isVoiceCapable)
|
||||
put("phoneType", phoneType)
|
||||
put("phoneTypeString", phoneTypeString)
|
||||
put("mmsUaProfUrl", mmsUaProfUrl)
|
||||
put("mmsUserAgent", mmsUserAgent)
|
||||
put("dnsServers", JSONArray(dnsServers))
|
||||
put("dnsSearchDomains", dnsSearchDomains)
|
||||
put("privateDnsServerName", privateDnsServerName)
|
||||
put("privateDnsActive", privateDnsActive)
|
||||
put("hasCaptivePortal", hasCaptivePortal)
|
||||
put("secureStringSettings", JSONObject(secureStringSettings))
|
||||
put("secureIntSettings", JSONObject(secureIntSettings))
|
||||
put("systemStringSettings", JSONObject(systemStringSettings))
|
||||
put("systemIntSettings", JSONObject(systemIntSettings))
|
||||
put("globalStringSettings", JSONObject(globalStringSettings))
|
||||
put("globalIntSettings", JSONObject(globalIntSettings))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String): RandomizedDeviceProfile {
|
||||
val root = JSONObject(json)
|
||||
val deviceInfoJson = root.getJSONObject("deviceInfo")
|
||||
return RandomizedDeviceProfile(
|
||||
schemaVersion = root.getInt("schemaVersion"),
|
||||
profileId = root.getString("profileId"),
|
||||
deviceInfo = DeviceInfo(
|
||||
manufacturer = deviceInfoJson.getString("manufacturer"),
|
||||
model = deviceInfoJson.getString("model"),
|
||||
brand = deviceInfoJson.getString("brand"),
|
||||
device = deviceInfoJson.getString("device"),
|
||||
product = deviceInfoJson.getString("product"),
|
||||
hardware = deviceInfoJson.getString("hardware"),
|
||||
board = deviceInfoJson.getString("board"),
|
||||
bootloader = deviceInfoJson.getString("bootloader"),
|
||||
display = deviceInfoJson.getString("display"),
|
||||
host = deviceInfoJson.getString("host")
|
||||
),
|
||||
androidRelease = root.getString("androidRelease"),
|
||||
buildIncremental = root.getString("buildIncremental"),
|
||||
buildDisplayId = root.getString("buildDisplayId"),
|
||||
buildFingerprint = root.getString("buildFingerprint"),
|
||||
buildHost = root.getString("buildHost"),
|
||||
buildTime = root.getLong("buildTime"),
|
||||
supportedAbis = jsonArrayToStringList(root.getJSONArray("supportedAbis")),
|
||||
supported32BitAbis = jsonArrayToStringList(root.getJSONArray("supported32BitAbis")),
|
||||
supported64BitAbis = jsonArrayToStringList(root.getJSONArray("supported64BitAbis")),
|
||||
androidId = root.getString("androidId"),
|
||||
gsfId = root.getString("gsfId"),
|
||||
advertisingId = root.getString("advertisingId"),
|
||||
wifiMacAddress = root.getString("wifiMacAddress"),
|
||||
bluetoothMacAddress = root.getString("bluetoothMacAddress"),
|
||||
ipAddress = root.optString("ipAddress").ifBlank { defaultFallbackIpAddress() },
|
||||
wifiSsid = root.getString("wifiSsid"),
|
||||
wifiRssi = root.getInt("wifiRssi"),
|
||||
localeTag = root.getString("localeTag"),
|
||||
countryIso = root.getString("countryIso"),
|
||||
timeZoneId = root.getString("timeZoneId"),
|
||||
timeZoneDisplayName = root.getString("timeZoneDisplayName"),
|
||||
networkType = root.getInt("networkType"),
|
||||
networkOperator = root.getString("networkOperator"),
|
||||
networkOperatorName = root.getString("networkOperatorName"),
|
||||
networkCountryIso = root.getString("networkCountryIso"),
|
||||
simCountryIso = root.getString("simCountryIso"),
|
||||
simOperator = root.getString("simOperator"),
|
||||
simOperatorName = root.getString("simOperatorName"),
|
||||
simState = root.getInt("simState"),
|
||||
hasIccCard = root.getBoolean("hasIccCard"),
|
||||
phoneCount = root.getInt("phoneCount"),
|
||||
isHearingAidCompatibilitySupported = root.getBoolean("isHearingAidCompatibilitySupported"),
|
||||
isTtySupported = root.getBoolean("isTtySupported"),
|
||||
isWorldPhone = root.getBoolean("isWorldPhone"),
|
||||
isNetworkRoaming = root.getBoolean("isNetworkRoaming"),
|
||||
isSmsCapable = root.getBoolean("isSmsCapable"),
|
||||
isVoiceCapable = root.getBoolean("isVoiceCapable"),
|
||||
phoneType = root.getInt("phoneType"),
|
||||
phoneTypeString = root.getString("phoneTypeString"),
|
||||
mmsUaProfUrl = root.getString("mmsUaProfUrl"),
|
||||
mmsUserAgent = root.getString("mmsUserAgent"),
|
||||
dnsServers = jsonArrayToStringList(root.getJSONArray("dnsServers")),
|
||||
dnsSearchDomains = root.getString("dnsSearchDomains"),
|
||||
privateDnsServerName = root.getString("privateDnsServerName"),
|
||||
privateDnsActive = root.getBoolean("privateDnsActive"),
|
||||
hasCaptivePortal = root.getBoolean("hasCaptivePortal"),
|
||||
secureStringSettings = jsonObjectToStringMap(root.getJSONObject("secureStringSettings")),
|
||||
secureIntSettings = jsonObjectToIntMap(root.getJSONObject("secureIntSettings")),
|
||||
systemStringSettings = jsonObjectToStringMap(root.getJSONObject("systemStringSettings")),
|
||||
systemIntSettings = jsonObjectToIntMap(root.getJSONObject("systemIntSettings")),
|
||||
globalStringSettings = jsonObjectToStringMap(root.getJSONObject("globalStringSettings")),
|
||||
globalIntSettings = jsonObjectToIntMap(root.getJSONObject("globalIntSettings"))
|
||||
)
|
||||
}
|
||||
|
||||
private fun jsonArrayToStringList(array: JSONArray): List<String> = buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
add(array.getString(index))
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonObjectToStringMap(jsonObject: JSONObject): Map<String, String> {
|
||||
return jsonObject.keys().asSequence().associateWith { jsonObject.getString(it) }
|
||||
}
|
||||
|
||||
private fun jsonObjectToIntMap(jsonObject: JSONObject): Map<String, Int> {
|
||||
return jsonObject.keys().asSequence().associateWith { jsonObject.getInt(it) }
|
||||
}
|
||||
|
||||
private fun defaultFallbackIpAddress(): String = "23.42.18.101"
|
||||
}
|
||||
}
|
||||
|
||||
object RandomizedDeviceProfileStore {
|
||||
private const val prefsName = "purrfectsnap_spoof"
|
||||
private const val schemaVersion = 5
|
||||
private const val profileKey = "randomized_device_profile"
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun getOrCreate(context: Context, logger: AbstractLogger, generationToken: String?): RandomizedDeviceProfile {
|
||||
val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||
val requestedToken = generationToken.orEmpty()
|
||||
prefs.getString(profileKey, null)?.let { raw ->
|
||||
runCatching {
|
||||
RandomizedDeviceProfile.fromJson(raw)
|
||||
}.onSuccess { profile ->
|
||||
val storedToken = prefs.getString("${profileKey}_token", "") ?: ""
|
||||
if (profile.schemaVersion == schemaVersion && storedToken == requestedToken) {
|
||||
logger.info("Loaded randomized device profile ${profile.profileId} (${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model})")
|
||||
return profile
|
||||
}
|
||||
}.onFailure {
|
||||
logger.warn("Failed to parse saved randomized device profile, regenerating: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val previousProfile = prefs.getString(profileKey, null)?.let { raw ->
|
||||
runCatching { RandomizedDeviceProfile.fromJson(raw) }.getOrNull()
|
||||
}
|
||||
val profile = generateProfile(previousProfile)
|
||||
prefs.edit()
|
||||
.putString(profileKey, profile.toJson().toString())
|
||||
.putString("${profileKey}_token", requestedToken)
|
||||
.putString("android_id", profile.androidId)
|
||||
.putString("advertising_id", profile.advertisingId)
|
||||
.putString("bluetooth_address", profile.bluetoothMacAddress)
|
||||
.putString("gsf_id", profile.gsfId)
|
||||
.putString("random_device", profile.deviceInfo.model)
|
||||
.putString("device_fingerprint", profile.buildFingerprint)
|
||||
.apply()
|
||||
|
||||
logger.info(
|
||||
"Generated randomized device profile ${profile.profileId}: " +
|
||||
"${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model}, " +
|
||||
"androidId=${profile.androidId}, ip=${profile.ipAddress}, locale=${profile.localeTag}, tz=${profile.timeZoneId}, " +
|
||||
"carrier=${profile.simOperatorName}"
|
||||
)
|
||||
return profile
|
||||
}
|
||||
|
||||
private fun generateProfile(previousProfile: RandomizedDeviceProfile?): RandomizedDeviceProfile {
|
||||
val eligibleDevices = DeviceSpoofer.getAvailableDevices().filter {
|
||||
DeviceSpoofer.getDeviceTemplate(it)?.capabilities?.let { capabilities ->
|
||||
capabilities.isSmsCapable && capabilities.isVoiceCapable && capabilities.isWorldPhone
|
||||
} == true
|
||||
}.ifEmpty { DeviceSpoofer.getAvailableDevices() }
|
||||
val deviceCandidates = eligibleDevices.filterNot {
|
||||
previousProfile != null &&
|
||||
DeviceSpoofer.getDeviceTemplate(it)?.deviceInfo?.model == previousProfile.deviceInfo.model
|
||||
}.ifEmpty { eligibleDevices }
|
||||
val deviceName = pick(deviceCandidates)
|
||||
val deviceTemplate = DeviceSpoofer.getDeviceTemplate(deviceName) ?: error("Missing device template for $deviceName")
|
||||
val regionCandidates = regionProfiles.filterNot {
|
||||
previousProfile != null &&
|
||||
it.localeTag == previousProfile.localeTag &&
|
||||
it.simOperatorName == previousProfile.simOperatorName
|
||||
}.ifEmpty { regionProfiles }
|
||||
val region = pick(regionCandidates)
|
||||
val buildCandidates = deviceTemplate.builds.filter { it.androidRelease in region.androidReleaseOptions }
|
||||
.ifEmpty { deviceTemplate.builds }
|
||||
val buildProfile = pick(buildCandidates)
|
||||
val deviceInfo = deviceTemplate.deviceInfo.copy(
|
||||
display = buildProfile.display,
|
||||
host = buildProfile.host,
|
||||
bootloader = buildProfile.bootloader ?: deviceTemplate.deviceInfo.bootloader
|
||||
)
|
||||
val androidRelease = buildProfile.androidRelease
|
||||
val buildIncremental = buildProfile.incremental
|
||||
val buildDisplayId = buildProfile.display
|
||||
val buildFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildProfile)
|
||||
val buildHost = buildProfile.host
|
||||
val buildTime = System.currentTimeMillis() - randomLong(45L, 220L) * 24L * 60L * 60L * 1000L
|
||||
val wifiMac = randomMacAddress()
|
||||
val bluetoothMac = randomMacAddress()
|
||||
val ipAddress = region.randomPublicIpAddress()
|
||||
val locale = Locale.forLanguageTag(region.localeTag)
|
||||
val timeZone = TimeZone.getTimeZone(region.timeZoneId)
|
||||
val capabilities = deviceTemplate.capabilities
|
||||
val secureStringSettings = mapOf(
|
||||
"accessibility_enabled" to "0",
|
||||
"speak_password" to "0",
|
||||
"allowed_geolocation_origins" to "",
|
||||
"install_non_market_apps" to "0",
|
||||
"device_provisioned" to "1",
|
||||
"enabled_notification_listeners" to ""
|
||||
)
|
||||
val secureIntSettings = mapOf(
|
||||
"input_method_selector_visibility" to 0,
|
||||
"accessibility_display_inversion_enabled" to 0,
|
||||
"enabled_accessibility_services" to 0,
|
||||
"skip_first_use_hints" to 0,
|
||||
"tts_default_synth" to 0
|
||||
)
|
||||
val systemStringSettings = mapOf(
|
||||
"dtmf_tone_type" to "normal",
|
||||
"mode_ringer_streams_affected" to "166",
|
||||
"mute_streams_affected" to "46",
|
||||
"show_password" to "1",
|
||||
"user_rotation" to "0"
|
||||
)
|
||||
val systemIntSettings = mapOf(
|
||||
"bluetooth_discoverability" to 0,
|
||||
"bluetooth_discoverability_timeout" to 120,
|
||||
"date_format" to 0,
|
||||
"end_button_behavior" to 2
|
||||
)
|
||||
val globalStringSettings = mapOf(
|
||||
"adb_enabled" to "0",
|
||||
"auto_time" to "1",
|
||||
"auto_time_zone" to "1",
|
||||
"development_settings_enabled" to "0",
|
||||
"stay_on_while_plugged_in" to "0",
|
||||
"usb_mass_storage_enabled" to "0",
|
||||
"wifi_networks_available_notification_on" to "0",
|
||||
"data_roaming" to "1"
|
||||
)
|
||||
val globalIntSettings = mapOf(
|
||||
"always_finish_activities" to 0,
|
||||
"animator_duration_scale" to 1,
|
||||
"http_proxy" to 0,
|
||||
"network_preference" to 1,
|
||||
"transition_animation_scale" to 1,
|
||||
"use_google_mail" to 1,
|
||||
"wait_for_debugger" to 0
|
||||
)
|
||||
|
||||
return RandomizedDeviceProfile(
|
||||
schemaVersion = schemaVersion,
|
||||
profileId = UUID.randomUUID().toString().substring(0, 8),
|
||||
deviceInfo = deviceInfo,
|
||||
androidRelease = androidRelease,
|
||||
buildIncremental = buildIncremental,
|
||||
buildDisplayId = buildDisplayId,
|
||||
buildFingerprint = buildFingerprint,
|
||||
buildHost = buildHost,
|
||||
buildTime = buildTime,
|
||||
supportedAbis = capabilities.supportedAbis,
|
||||
supported32BitAbis = capabilities.supported32BitAbis,
|
||||
supported64BitAbis = capabilities.supported64BitAbis,
|
||||
androidId = randomHex(16),
|
||||
gsfId = randomHex(16),
|
||||
advertisingId = UUID.randomUUID().toString(),
|
||||
wifiMacAddress = wifiMac,
|
||||
bluetoothMacAddress = bluetoothMac,
|
||||
ipAddress = ipAddress,
|
||||
wifiSsid = region.randomWifiSsid(),
|
||||
wifiRssi = random.nextInt(-72, -36),
|
||||
localeTag = locale.toLanguageTag(),
|
||||
countryIso = region.countryIso,
|
||||
timeZoneId = region.timeZoneId,
|
||||
timeZoneDisplayName = timeZone.getDisplayName(false, TimeZone.SHORT, locale),
|
||||
networkType = 13,
|
||||
networkOperator = region.networkOperator,
|
||||
networkOperatorName = region.networkOperatorName,
|
||||
networkCountryIso = region.countryIso.lowercase(Locale.US),
|
||||
simCountryIso = region.countryIso.lowercase(Locale.US),
|
||||
simOperator = region.simOperator,
|
||||
simOperatorName = region.simOperatorName,
|
||||
simState = 5,
|
||||
hasIccCard = true,
|
||||
phoneCount = capabilities.phoneCount,
|
||||
isHearingAidCompatibilitySupported = capabilities.isHearingAidCompatibilitySupported,
|
||||
isTtySupported = capabilities.isTtySupported,
|
||||
isWorldPhone = capabilities.isWorldPhone,
|
||||
isNetworkRoaming = false,
|
||||
isSmsCapable = capabilities.isSmsCapable,
|
||||
isVoiceCapable = capabilities.isVoiceCapable,
|
||||
phoneType = capabilities.phoneType,
|
||||
phoneTypeString = capabilities.phoneTypeString,
|
||||
mmsUaProfUrl = "",
|
||||
mmsUserAgent = region.mmsUserAgent(deviceTemplate.marketingName, androidRelease),
|
||||
dnsServers = region.dnsServers.sortedBy { random.nextInt() }.take(2),
|
||||
dnsSearchDomains = region.dnsSearchDomains,
|
||||
privateDnsServerName = region.privateDnsServerName,
|
||||
privateDnsActive = true,
|
||||
hasCaptivePortal = false,
|
||||
secureStringSettings = secureStringSettings,
|
||||
secureIntSettings = secureIntSettings,
|
||||
systemStringSettings = systemStringSettings,
|
||||
systemIntSettings = systemIntSettings,
|
||||
globalStringSettings = globalStringSettings,
|
||||
globalIntSettings = globalIntSettings
|
||||
)
|
||||
}
|
||||
|
||||
private fun randomHex(length: Int): String {
|
||||
val chars = CharArray(length)
|
||||
val alphabet = "0123456789abcdef"
|
||||
for (index in chars.indices) {
|
||||
chars[index] = alphabet[random.nextInt(alphabet.length)]
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
|
||||
private fun randomDigits(length: Int): String {
|
||||
val chars = CharArray(length)
|
||||
for (index in chars.indices) {
|
||||
chars[index] = ('0'.code + random.nextInt(10)).toChar()
|
||||
}
|
||||
return String(chars)
|
||||
}
|
||||
|
||||
private fun randomMacAddress(): String {
|
||||
val bytes = ByteArray(6)
|
||||
random.nextBytes(bytes)
|
||||
bytes[0] = (bytes[0].toInt() and 0xFE or 0x02).toByte()
|
||||
return bytes.joinToString(":") { "%02x".format(it.toInt() and 0xFF) }
|
||||
}
|
||||
|
||||
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
|
||||
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
|
||||
generateSequence { random.nextInt(1, 224) }
|
||||
.first { candidate ->
|
||||
candidate != 10 &&
|
||||
candidate != 127 &&
|
||||
candidate != 169 &&
|
||||
candidate != 172 &&
|
||||
candidate != 192
|
||||
}
|
||||
}
|
||||
val secondOctet = random.nextInt(1, 255)
|
||||
val thirdOctet = random.nextInt(1, 255)
|
||||
val fourthOctet = random.nextInt(2, 255)
|
||||
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
|
||||
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
|
||||
}
|
||||
|
||||
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
|
||||
require(maxExclusive > minInclusive)
|
||||
val bound = maxExclusive - minInclusive
|
||||
var bits: Long
|
||||
var candidate: Long
|
||||
do {
|
||||
bits = random.nextLong() ushr 1
|
||||
candidate = bits % bound
|
||||
} while (bits - candidate + (bound - 1) < 0L)
|
||||
return minInclusive + candidate
|
||||
}
|
||||
|
||||
private fun <T> pick(values: List<T>): T = values[random.nextInt(values.size)]
|
||||
|
||||
private data class RegionProfile(
|
||||
val localeTag: String,
|
||||
val countryIso: String,
|
||||
val timeZoneId: String,
|
||||
val networkOperator: String,
|
||||
val networkOperatorName: String,
|
||||
val simOperator: String,
|
||||
val simOperatorName: String,
|
||||
val dnsServers: List<String>,
|
||||
val dnsSearchDomains: String,
|
||||
val privateDnsServerName: String,
|
||||
val timeFormat: String,
|
||||
val androidReleaseOptions: List<String>,
|
||||
val wifiPrefixes: List<String>,
|
||||
val ipPrefixes: List<Int>
|
||||
) {
|
||||
fun randomWifiSsid(): String = "${pick(wifiPrefixes)}-${randomDigits(4)}"
|
||||
fun randomPublicIpAddress(): String = randomPublicIpv4(ipPrefixes)
|
||||
|
||||
fun mmsUserAgent(model: String, androidRelease: String): String {
|
||||
return "$model/$androidRelease"
|
||||
}
|
||||
}
|
||||
|
||||
private val regionProfiles = listOf(
|
||||
RegionProfile(
|
||||
localeTag = "en-US",
|
||||
countryIso = "US",
|
||||
timeZoneId = "America/New_York",
|
||||
networkOperator = "310260",
|
||||
networkOperatorName = "T-Mobile",
|
||||
simOperator = "310260",
|
||||
simOperatorName = "T-Mobile",
|
||||
dnsServers = listOf("8.8.8.8", "8.8.4.4", "1.1.1.1"),
|
||||
dnsSearchDomains = "hsd1.ny.comcast.net",
|
||||
privateDnsServerName = "dns.google",
|
||||
timeFormat = "12",
|
||||
androidReleaseOptions = listOf("14", "15"),
|
||||
wifiPrefixes = listOf("TP-Link", "NETGEAR", "XFINITY", "HomeWiFi"),
|
||||
ipPrefixes = listOf(23, 24, 45, 47, 66, 67, 68, 69, 72, 73, 98, 104, 107, 108, 162, 184, 198, 199)
|
||||
),
|
||||
RegionProfile(
|
||||
localeTag = "en-GB",
|
||||
countryIso = "GB",
|
||||
timeZoneId = "Europe/London",
|
||||
networkOperator = "23430",
|
||||
networkOperatorName = "EE",
|
||||
simOperator = "23430",
|
||||
simOperatorName = "EE",
|
||||
dnsServers = listOf("1.1.1.1", "1.0.0.1", "8.8.8.8"),
|
||||
dnsSearchDomains = "bb.sky.com",
|
||||
privateDnsServerName = "one.one.one.one",
|
||||
timeFormat = "24",
|
||||
androidReleaseOptions = listOf("14", "15"),
|
||||
wifiPrefixes = listOf("Sky", "BT-Hub", "VirginMedia", "Linksys"),
|
||||
ipPrefixes = listOf(51, 62, 77, 81, 86, 87, 88, 89, 90, 91, 92, 109, 141, 176, 185, 188)
|
||||
),
|
||||
RegionProfile(
|
||||
localeTag = "de-DE",
|
||||
countryIso = "DE",
|
||||
timeZoneId = "Europe/Berlin",
|
||||
networkOperator = "26202",
|
||||
networkOperatorName = "Vodafone DE",
|
||||
simOperator = "26202",
|
||||
simOperatorName = "Vodafone DE",
|
||||
dnsServers = listOf("9.9.9.9", "149.112.112.112", "1.1.1.1"),
|
||||
dnsSearchDomains = "fritz.box",
|
||||
privateDnsServerName = "dns.quad9.net",
|
||||
timeFormat = "24",
|
||||
androidReleaseOptions = listOf("14", "15"),
|
||||
wifiPrefixes = listOf("FRITZBox", "Vodafone", "Telekom", "WLAN"),
|
||||
ipPrefixes = listOf(2, 5, 31, 37, 46, 79, 80, 84, 85, 87, 91, 93, 95, 109, 134, 176, 178, 188)
|
||||
),
|
||||
RegionProfile(
|
||||
localeTag = "en-IN",
|
||||
countryIso = "IN",
|
||||
timeZoneId = "Asia/Kolkata",
|
||||
networkOperator = "405874",
|
||||
networkOperatorName = "Jio",
|
||||
simOperator = "405874",
|
||||
simOperatorName = "Jio",
|
||||
dnsServers = listOf("1.1.1.1", "8.8.8.8", "9.9.9.9"),
|
||||
dnsSearchDomains = "airtelbroadband.in",
|
||||
privateDnsServerName = "dns.google",
|
||||
timeFormat = "12",
|
||||
androidReleaseOptions = listOf("14", "15"),
|
||||
wifiPrefixes = listOf("JioFiber", "Airtel", "ACTFibernet", "HomeNet"),
|
||||
ipPrefixes = listOf(14, 27, 42, 49, 59, 61, 101, 103, 106, 117, 122, 125, 157, 182)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
|
||||
class DisableTelecomFramework: Feature("Disable Telecom Framework") {
|
||||
override fun init() {
|
||||
if (!context.config.global.disableTelecomFramework.get()) return
|
||||
if (!context.config.global.disableTelecomFramework.get() && !context.config.messaging.blockCalls.get()) return
|
||||
|
||||
ContextWrapper::class.java.hook("getSystemService", HookStage.BEFORE) { param ->
|
||||
if (param.arg<Any>(0).toString() == "telecom") param.setResult(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
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.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class BlockCalls : Feature("Block Calls") {
|
||||
private fun isBlockedCallNotificationType(type: String?): Boolean {
|
||||
return type?.lowercase() in setOf(
|
||||
"initiate_audio",
|
||||
"initiate_video",
|
||||
"abandon_audio",
|
||||
"abandon_video"
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldBlockVolatilePayload(eventData: ProtoReader): Boolean {
|
||||
val dump = eventData.toString().lowercase()
|
||||
return listOf(
|
||||
"\"calluuid\"",
|
||||
"\"callaction\"",
|
||||
"\"messagetype\":\"caller_push\"",
|
||||
"\"messagetype\":\"streamer_data_v2\"",
|
||||
"\"messagetype\":\"callee_push\"",
|
||||
"\"messagetype\":\"caller_hangup\"",
|
||||
"\"messagetype\":\"call_end\""
|
||||
).any { it in dump }
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.messaging.blockCalls.get()) return
|
||||
|
||||
runCatching {
|
||||
findClass("com.google.firebase.messaging.FirebaseMessagingService")
|
||||
.methods
|
||||
.first {
|
||||
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
|
||||
it.returnType == Void::class.javaPrimitiveType &&
|
||||
it.parameterCount == 1 &&
|
||||
it.parameterTypes[0] == Intent::class.java
|
||||
}
|
||||
.hook(HookStage.BEFORE) { param ->
|
||||
val intent = param.argNullable<Intent>(0) ?: return@hook
|
||||
if (!isBlockedCallNotificationType(intent.getStringExtra("type"))) return@hook
|
||||
|
||||
context.log.verbose("Blocked Firebase call message ${intent.getStringExtra("type")}", "BlockCalls")
|
||||
param.setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
NotificationManager::class.java.findRestrictedMethod { it.name == "notifyAsUser" }?.hook(HookStage.BEFORE) { param ->
|
||||
val notification = param.argNullable<Notification>(2) ?: return@hook
|
||||
val notificationType = notification.extras
|
||||
?.getBundle("system_notification_extras")
|
||||
?.getString("notification_type")
|
||||
|
||||
if (!isBlockedCallNotificationType(notificationType)) return@hook
|
||||
|
||||
context.log.verbose("Blocked call notification $notificationType", "BlockCalls")
|
||||
param.setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
|
||||
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
|
||||
val duplicate = buffer.duplicate().apply { position(0) }
|
||||
val bytes = ByteArray(duplicate.limit())
|
||||
duplicate.get(bytes)
|
||||
|
||||
val reader = ProtoReader(bytes)
|
||||
val eventType = reader.getString(1, 1) ?: return@hook
|
||||
if (eventType != "volatile") return@hook
|
||||
|
||||
val eventData = reader.followPath(1, 2) ?: return@hook
|
||||
if (!shouldBlockVolatilePayload(eventData)) return@hook
|
||||
|
||||
context.log.verbose("Blocked volatile call payload", "BlockCalls")
|
||||
param.setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
val talkCoreNames = listOf(
|
||||
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
|
||||
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
|
||||
"com.snapchat.talkcore.TalkCore\$CppProxy"
|
||||
)
|
||||
|
||||
talkCoreNames.forEach { className ->
|
||||
runCatching {
|
||||
findClass(className).apply {
|
||||
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
|
||||
val params = param.argNullable<Any>(0)
|
||||
val conversationId = params?.getObjectFieldOrNull("mConversationId")?.toString()
|
||||
val inCall = params?.getObjectFieldOrNull("mInCall") as? Boolean
|
||||
context.log.verbose(
|
||||
"Blocked talk session update inCall=$inCall convo=$conversationId",
|
||||
"BlockCalls"
|
||||
)
|
||||
param.setResult(null)
|
||||
}
|
||||
hook("disposeTSCallingSession", HookStage.BEFORE) { param ->
|
||||
context.log.verbose("Blocked talk session dispose", "BlockCalls")
|
||||
param.setResult(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
|
||||
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
|
||||
"com.snapchat.talkcore.TSCallingStateUpdateParams"
|
||||
).forEach { className ->
|
||||
runCatching {
|
||||
findClass(className).hookConstructor(HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
|
||||
if (!inCall) return@hookConstructor
|
||||
|
||||
instance.setObjectField("mInCall", false)
|
||||
context.log.verbose("Forced TSCallingStateUpdateParams.mInCall=false", "BlockCalls")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
val viewName = event.viewClassName.lowercase()
|
||||
val parentName = event.parent.javaClass.name.lowercase()
|
||||
val exactCallUi = setOf(
|
||||
"com.snap.talk.callviewwrapper",
|
||||
"com.snap.talk.core.callcontainer"
|
||||
)
|
||||
val callUiParents = setOf(
|
||||
"com.snap.talk.core.callcontainer"
|
||||
)
|
||||
|
||||
if (viewName in exactCallUi || parentName in callUiParents) {
|
||||
context.log.verbose(
|
||||
"Suppressed view ${event.viewClassName} parent=${event.parent.javaClass.name}",
|
||||
"BlockCalls"
|
||||
)
|
||||
event.view.hideViewCompletely()
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
if (viewName.endsWith("callbuttonsview") ||
|
||||
(viewName.contains("call") && (
|
||||
viewName.contains("overlay") ||
|
||||
viewName.contains("incoming") ||
|
||||
viewName.contains("ringing") ||
|
||||
viewName.contains("ringer")
|
||||
))
|
||||
) {
|
||||
event.view.hideViewCompletely()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,13 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
|
||||
|
||||
override fun init() {
|
||||
val hideUiComponents by context.config.userInterface.hideUiComponents
|
||||
val blockCalls = context.config.messaging.blockCalls.get()
|
||||
|
||||
val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons")
|
||||
val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons")
|
||||
val hideProfileCallButtons = blockCalls || hideUiComponents.contains("hide_profile_call_buttons")
|
||||
val hideChatCallButtons = blockCalls || hideUiComponents.contains("hide_chat_call_buttons")
|
||||
val callStartConfirmation = context.config.messaging.callStartConfirmation.get()
|
||||
|
||||
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return
|
||||
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation && !blockCalls) return
|
||||
|
||||
var actionSheetVideoCallButtonId = -1
|
||||
var actionSheetAudioCallButtonId = -1
|
||||
@@ -111,7 +112,7 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
if (callStartConfirmation) {
|
||||
if (callStartConfirmation || blockCalls) {
|
||||
(runCatching { findClass("com.snap.valdi.views.ValdiRootView") }.getOrNull()
|
||||
?: findClass("com.snap.composer.views.ComposerRootView"))
|
||||
.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
|
||||
@@ -122,6 +123,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
|
||||
if (childComposerView.children().count {
|
||||
it::class.java == childComposerView::class.java
|
||||
} != 2) return@hook
|
||||
if (blockCalls) {
|
||||
param.setResult(true)
|
||||
return@hook
|
||||
}
|
||||
hookTouchEvent(param, param.arg(0)) {
|
||||
param.invokeOriginal()
|
||||
}
|
||||
@@ -131,6 +136,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
|
||||
val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook
|
||||
if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook
|
||||
|
||||
if (blockCalls) {
|
||||
param.setResult(true)
|
||||
return@hook
|
||||
}
|
||||
hookTouchEvent(param, param.arg(0)) {
|
||||
arrayOf(
|
||||
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
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 me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import java.nio.ByteBuffer
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.LinkedHashSet
|
||||
import java.util.Locale
|
||||
|
||||
class CallMetadataNotifier : Feature("Call Metadata Notifier") {
|
||||
private data class CapturedCallMetadata(
|
||||
var callUuid: String? = null,
|
||||
var attemptId: String? = null,
|
||||
var media: String? = null,
|
||||
var isGroup: String? = null,
|
||||
var scopeId: String? = null,
|
||||
var ipv4Address: String? = null,
|
||||
var startTimestamp: Long? = null,
|
||||
var endedTimestamp: Long? = null,
|
||||
val messageTypes: LinkedHashSet<String> = linkedSetOf(),
|
||||
val rawPayloads: LinkedHashSet<String> = linkedSetOf()
|
||||
) {
|
||||
fun reset() {
|
||||
callUuid = null
|
||||
attemptId = null
|
||||
media = null
|
||||
isGroup = null
|
||||
scopeId = null
|
||||
ipv4Address = null
|
||||
startTimestamp = null
|
||||
endedTimestamp = null
|
||||
messageTypes.clear()
|
||||
rawPayloads.clear()
|
||||
}
|
||||
|
||||
fun hasData(): Boolean {
|
||||
return callUuid != null ||
|
||||
attemptId != null ||
|
||||
media != null ||
|
||||
isGroup != null ||
|
||||
scopeId != null ||
|
||||
ipv4Address != null ||
|
||||
messageTypes.isNotEmpty() ||
|
||||
rawPayloads.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationChannelId = "call_metadata_notifier"
|
||||
private val metadata = CapturedCallMetadata()
|
||||
private var wasInCall = false
|
||||
private val translation by lazy { context.translation.getCategory("call_metadata_notifier") }
|
||||
private val notificationManager by lazy {
|
||||
context.androidContext.getSystemService(NotificationManager::class.java).apply {
|
||||
createNotificationChannel(
|
||||
NotificationChannel(
|
||||
notificationChannelId,
|
||||
translation["notification_channel_name"],
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAscii(bytes: ByteArray): Boolean {
|
||||
return bytes.all { it in 0x20..0x7E || it == 0x0A.toByte() || it == 0x0D.toByte() }
|
||||
}
|
||||
|
||||
private fun collectStrings(reader: ProtoReader, depth: Int = 0, output: MutableList<String>) {
|
||||
if (depth > 5) return
|
||||
reader.eachBuffer { _, buffer ->
|
||||
if (buffer.isEmpty()) return@eachBuffer
|
||||
if (isAscii(buffer)) {
|
||||
output.add(buffer.toString(Charsets.UTF_8))
|
||||
}
|
||||
runCatching { ProtoReader(buffer) }.getOrNull()?.let {
|
||||
collectStrings(it, depth + 1, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseObjectPayload(payload: String): Map<String, String> {
|
||||
val regex = Regex("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|true|false|-?\\d+(?:\\.\\d+)?)")
|
||||
return regex.findAll(payload).associate { match ->
|
||||
val key = match.groupValues[1]
|
||||
val rawValue = match.groupValues[2]
|
||||
key to rawValue.trim('"')
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun updateFromPayload(payload: String) {
|
||||
if (!payload.startsWith("{") || !payload.endsWith("}")) return
|
||||
|
||||
val parsed = parseObjectPayload(payload)
|
||||
if (parsed.isEmpty()) return
|
||||
|
||||
parsed["callUuid"]?.let { newCallUuid ->
|
||||
if (metadata.callUuid != null && metadata.callUuid != newCallUuid && metadata.hasData()) {
|
||||
notifyCallEnded("new_call_boundary")
|
||||
}
|
||||
metadata.callUuid = newCallUuid
|
||||
}
|
||||
|
||||
parsed["attemptId"]?.let { metadata.attemptId = it }
|
||||
parsed["media"]?.let { metadata.media = it }
|
||||
parsed["isGroup"]?.let { metadata.isGroup = it }
|
||||
parsed["scopeId"]?.let { metadata.scopeId = it }
|
||||
parsed["ipv4Address"]?.let { metadata.ipv4Address = it }
|
||||
parsed["messageType"]?.let { metadata.messageTypes.add(it) }
|
||||
parsed["callAction"]?.let {
|
||||
metadata.messageTypes.add("callAction:$it")
|
||||
if (it.equals("START", ignoreCase = true) && metadata.startTimestamp == null) {
|
||||
metadata.startTimestamp = System.currentTimeMillis()
|
||||
wasInCall = true
|
||||
}
|
||||
if (it.equals("END", ignoreCase = true) || it.equals("HANGUP", ignoreCase = true)) {
|
||||
handleCallEnded("call_action:$it")
|
||||
}
|
||||
}
|
||||
metadata.rawPayloads.add(payload)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleVolatileEvent(reader: ProtoReader) {
|
||||
val payloads = mutableListOf<String>()
|
||||
collectStrings(reader, output = payloads)
|
||||
|
||||
payloads.forEach { payload ->
|
||||
updateFromPayload(payload)
|
||||
val normalized = payload.lowercase(Locale.ROOT)
|
||||
if ("caller_hangup" in normalized || "\"messagetype\":\"call_end\"" in normalized) {
|
||||
handleCallEnded("volatile_end")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleCallStarted(reason: String) {
|
||||
if (!wasInCall) {
|
||||
wasInCall = true
|
||||
if (metadata.startTimestamp == null) {
|
||||
metadata.startTimestamp = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
metadata.messageTypes.add("state:$reason")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleCallEnded(reason: String) {
|
||||
val shouldNotify = wasInCall || metadata.hasData()
|
||||
wasInCall = false
|
||||
if (!shouldNotify) return
|
||||
notifyCallEnded(reason)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun notifyCallEnded(reason: String) {
|
||||
if (!metadata.hasData()) return
|
||||
|
||||
metadata.endedTimestamp = System.currentTimeMillis()
|
||||
val lines = buildList {
|
||||
metadata.ipv4Address?.let { add("IP: $it") }
|
||||
metadata.scopeId?.let { add("Scope ID: $it") }
|
||||
metadata.callUuid?.let { add("Call UUID: $it") }
|
||||
metadata.attemptId?.let { add("Attempt ID: $it") }
|
||||
metadata.media?.let { add("Media: $it") }
|
||||
metadata.isGroup?.let { add("Group Call: $it") }
|
||||
if (metadata.startTimestamp != null) {
|
||||
add("Started: ${DateFormat.getDateTimeInstance().format(Date(metadata.startTimestamp!!))}")
|
||||
}
|
||||
if (metadata.endedTimestamp != null) {
|
||||
add("Ended: ${DateFormat.getDateTimeInstance().format(Date(metadata.endedTimestamp!!))}")
|
||||
}
|
||||
if (metadata.messageTypes.isNotEmpty()) {
|
||||
add("Events: ${metadata.messageTypes.joinToString(", ")}")
|
||||
}
|
||||
add("Reason: $reason")
|
||||
}
|
||||
|
||||
val notification = Notification.Builder(context.androidContext, notificationChannelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(translation["notification_title"])
|
||||
.setContentText(lines.firstOrNull() ?: translation["notification_empty"])
|
||||
.setStyle(Notification.BigTextStyle().bigText(lines.joinToString("\n")))
|
||||
.setAutoCancel(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
notificationManager.notify((metadata.callUuid ?: metadata.attemptId ?: reason).hashCode(), notification)
|
||||
metadata.reset()
|
||||
wasInCall = false
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.messaging.callMetadataNotifier.get()) return
|
||||
|
||||
runCatching {
|
||||
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
|
||||
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
|
||||
val duplicate = buffer.duplicate().apply { position(0) }
|
||||
val bytes = ByteArray(duplicate.limit())
|
||||
duplicate.get(bytes)
|
||||
|
||||
val reader = ProtoReader(bytes)
|
||||
if (reader.getString(1, 1) != "volatile") return@hook
|
||||
val eventData = reader.followPath(1, 2) ?: return@hook
|
||||
handleVolatileEvent(eventData)
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.google.firebase.messaging.FirebaseMessagingService")
|
||||
.methods
|
||||
.first {
|
||||
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
|
||||
it.returnType == Void::class.javaPrimitiveType &&
|
||||
it.parameterCount == 1 &&
|
||||
it.parameterTypes[0] == Intent::class.java
|
||||
}
|
||||
.hook(HookStage.BEFORE) { param ->
|
||||
val intent = param.argNullable<Intent>(0) ?: return@hook
|
||||
when (intent.getStringExtra("type")?.lowercase(Locale.ROOT)) {
|
||||
"abandon_audio", "abandon_video" -> handleCallEnded("firebase:${intent.getStringExtra("type")}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
|
||||
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
|
||||
"com.snapchat.talkcore.TalkCore\$CppProxy"
|
||||
).forEach { className ->
|
||||
runCatching {
|
||||
findClass(className).apply {
|
||||
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
|
||||
val params = param.argNullable<Any>(0) ?: return@hook
|
||||
val inCall = params.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hook
|
||||
if (inCall) handleCallStarted("talkcore:update_true")
|
||||
else handleCallEnded("talkcore:update_false")
|
||||
}
|
||||
hook("disposeTSCallingSession", HookStage.BEFORE) {
|
||||
handleCallEnded("talkcore:dispose")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
|
||||
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
|
||||
"com.snapchat.talkcore.TSCallingStateUpdateParams"
|
||||
).forEach { className ->
|
||||
runCatching {
|
||||
findClass(className).hookConstructor(HookStage.AFTER) { param ->
|
||||
val instance = param.thisObject<Any>()
|
||||
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
|
||||
if (inCall) handleCallStarted("params_ctor:true")
|
||||
else handleCallEnded("params_ctor:false")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.sin
|
||||
|
||||
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
|
||||
private val seenIncomingMessageIds = LinkedHashSet<Long>()
|
||||
private val maxTrackedMessages = 512
|
||||
|
||||
private data class BubbleSpec(
|
||||
val durationMs: Int,
|
||||
val startFreqHz: Double,
|
||||
val endFreqHz: Double,
|
||||
val overtoneFreqHz: Double,
|
||||
val amplitude: Double
|
||||
)
|
||||
|
||||
private data class BubbleStep(
|
||||
val spec: BubbleSpec,
|
||||
val pauseAfterMs: Long = 0L
|
||||
)
|
||||
|
||||
private val iMessageSendBubble = BubbleSpec(
|
||||
durationMs = 78,
|
||||
startFreqHz = 1160.0,
|
||||
endFreqHz = 690.0,
|
||||
overtoneFreqHz = 1820.0,
|
||||
amplitude = 0.50
|
||||
)
|
||||
|
||||
private val iMessageReceiveBubble = BubbleSpec(
|
||||
durationMs = 92,
|
||||
startFreqHz = 1040.0,
|
||||
endFreqHz = 640.0,
|
||||
overtoneFreqHz = 1680.0,
|
||||
amplitude = 0.46
|
||||
)
|
||||
|
||||
private val whatsappSendBubble = BubbleSpec(
|
||||
durationMs = 86,
|
||||
startFreqHz = 860.0,
|
||||
endFreqHz = 520.0,
|
||||
overtoneFreqHz = 1410.0,
|
||||
amplitude = 0.52
|
||||
)
|
||||
|
||||
private val whatsappReceiveBubble = BubbleSpec(
|
||||
durationMs = 94,
|
||||
startFreqHz = 920.0,
|
||||
endFreqHz = 560.0,
|
||||
overtoneFreqHz = 1520.0,
|
||||
amplitude = 0.50
|
||||
)
|
||||
|
||||
private val telegramSendPrimary = BubbleSpec(
|
||||
durationMs = 58,
|
||||
startFreqHz = 1110.0,
|
||||
endFreqHz = 820.0,
|
||||
overtoneFreqHz = 1710.0,
|
||||
amplitude = 0.42
|
||||
)
|
||||
|
||||
private val telegramSendAccent = BubbleSpec(
|
||||
durationMs = 34,
|
||||
startFreqHz = 1360.0,
|
||||
endFreqHz = 980.0,
|
||||
overtoneFreqHz = 2060.0,
|
||||
amplitude = 0.22
|
||||
)
|
||||
|
||||
private val telegramReceivePrimary = BubbleSpec(
|
||||
durationMs = 72,
|
||||
startFreqHz = 1080.0,
|
||||
endFreqHz = 780.0,
|
||||
overtoneFreqHz = 1680.0,
|
||||
amplitude = 0.44
|
||||
)
|
||||
|
||||
private val telegramReceiveAccent = BubbleSpec(
|
||||
durationMs = 42,
|
||||
startFreqHz = 1280.0,
|
||||
endFreqHz = 940.0,
|
||||
overtoneFreqHz = 1940.0,
|
||||
amplitude = 0.18
|
||||
)
|
||||
|
||||
private val subtleSendBubble = BubbleSpec(
|
||||
durationMs = 60,
|
||||
startFreqHz = 760.0,
|
||||
endFreqHz = 520.0,
|
||||
overtoneFreqHz = 1180.0,
|
||||
amplitude = 0.26
|
||||
)
|
||||
|
||||
private val subtleReceiveBubble = BubbleSpec(
|
||||
durationMs = 66,
|
||||
startFreqHz = 800.0,
|
||||
endFreqHz = 560.0,
|
||||
overtoneFreqHz = 1260.0,
|
||||
amplitude = 0.24
|
||||
)
|
||||
|
||||
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
|
||||
private fun buildBubblePcm(spec: BubbleSpec, sampleRate: Int = 44_100): ByteArray {
|
||||
val sampleCount = (sampleRate * (spec.durationMs / 1000.0)).toInt().coerceAtLeast(1)
|
||||
val pcm = ByteArray(sampleCount * 2)
|
||||
for (i in 0 until sampleCount) {
|
||||
val progress = i.toDouble() / sampleCount.toDouble()
|
||||
val envelope = exp(-4.8 * progress) * (1.0 - exp(-20.0 * progress))
|
||||
val freq = spec.startFreqHz + (spec.endFreqHz - spec.startFreqHz) * progress
|
||||
val t = i.toDouble() / sampleRate.toDouble()
|
||||
val fundamental = sin(2.0 * PI * freq * t)
|
||||
val overtone = 0.18 * sin(2.0 * PI * spec.overtoneFreqHz * t)
|
||||
val airyTail = 0.08 * sin(2.0 * PI * (freq * 0.48) * t)
|
||||
val warmth = 0.14 * sin(2.0 * PI * (freq * 0.24) * t)
|
||||
val sample = ((fundamental + overtone + airyTail + warmth) * envelope * spec.amplitude)
|
||||
.coerceIn(-1.0, 1.0)
|
||||
val shortValue = (sample * Short.MAX_VALUE).toInt().toShort()
|
||||
pcm[i * 2] = (shortValue.toInt() and 0xFF).toByte()
|
||||
pcm[i * 2 + 1] = ((shortValue.toInt() shr 8) and 0xFF).toByte()
|
||||
}
|
||||
return pcm
|
||||
}
|
||||
|
||||
private fun playBubble(spec: BubbleSpec) {
|
||||
if (context.isMainActivityPaused) return
|
||||
context.executeAsync {
|
||||
val sampleRate = 44_100
|
||||
val pcm = buildBubblePcm(spec, sampleRate)
|
||||
val audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
pcm.size,
|
||||
AudioTrack.MODE_STATIC,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE
|
||||
)
|
||||
runCatching {
|
||||
audioTrack.write(pcm, 0, pcm.size)
|
||||
audioTrack.play()
|
||||
delay(spec.durationMs.toLong() + 24L)
|
||||
}.also {
|
||||
runCatching {
|
||||
audioTrack.stop()
|
||||
audioTrack.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playBubbleSequence(steps: List<BubbleStep>) {
|
||||
if (context.isMainActivityPaused) return
|
||||
context.executeAsync {
|
||||
steps.forEach { step ->
|
||||
val sampleRate = 44_100
|
||||
val pcm = buildBubblePcm(step.spec, sampleRate)
|
||||
val audioTrack = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
pcm.size,
|
||||
AudioTrack.MODE_STATIC,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE
|
||||
)
|
||||
runCatching {
|
||||
audioTrack.write(pcm, 0, pcm.size)
|
||||
audioTrack.play()
|
||||
delay(step.spec.durationMs.toLong() + step.pauseAfterMs + 18L)
|
||||
}.also {
|
||||
runCatching {
|
||||
audioTrack.stop()
|
||||
audioTrack.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playStyledSend() {
|
||||
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
|
||||
"imessage" -> playBubble(iMessageSendBubble)
|
||||
"whatsapp" -> playBubble(whatsappSendBubble)
|
||||
"telegram" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(telegramSendPrimary, pauseAfterMs = 16L),
|
||||
BubbleStep(telegramSendAccent)
|
||||
)
|
||||
)
|
||||
else -> playBubble(subtleSendBubble)
|
||||
}
|
||||
}
|
||||
|
||||
private fun playStyledReceive() {
|
||||
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
|
||||
"imessage" -> playBubble(iMessageReceiveBubble)
|
||||
"whatsapp" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(whatsappReceiveBubble, pauseAfterMs = 12L),
|
||||
BubbleStep(
|
||||
whatsappReceiveBubble.copy(
|
||||
durationMs = 42,
|
||||
startFreqHz = 1210.0,
|
||||
endFreqHz = 860.0,
|
||||
overtoneFreqHz = 1980.0,
|
||||
amplitude = 0.20
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
"telegram" -> playBubbleSequence(
|
||||
listOf(
|
||||
BubbleStep(telegramReceivePrimary, pauseAfterMs = 14L),
|
||||
BubbleStep(telegramReceiveAccent)
|
||||
)
|
||||
)
|
||||
else -> playBubble(subtleReceiveBubble)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markSeen(messageId: Long): Boolean {
|
||||
synchronized(seenIncomingMessageIds) {
|
||||
val added = seenIncomingMessageIds.add(messageId)
|
||||
while (seenIncomingMessageIds.size > maxTrackedMessages) {
|
||||
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
|
||||
}
|
||||
return added
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (context.config.messaging.conversationSoundEffectsStyle.get() == "disabled") return
|
||||
|
||||
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
|
||||
val activeConversationId = currentConversationId() ?: return@subscribe
|
||||
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
|
||||
|
||||
event.addCallbackResult("onSuccess") {
|
||||
playStyledSend()
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(ConversationUpdateEvent::class) { event ->
|
||||
val activeConversationId = currentConversationId() ?: return@subscribe
|
||||
if (event.conversationId != activeConversationId) return@subscribe
|
||||
|
||||
val myUserId = context.database.myUserId ?: return@subscribe
|
||||
|
||||
event.messages
|
||||
.asSequence()
|
||||
.filter { it.senderId?.toString() != myUserId }
|
||||
.mapNotNull { it.messageDescriptor?.messageId }
|
||||
.filter { markSeen(it) }
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
playStyledReceive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,14 @@ class Messaging : Feature("Messaging") {
|
||||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
|
||||
}
|
||||
|
||||
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
|
||||
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofViewingGalleryPresence.get()
|
||||
}
|
||||
|
||||
private fun shouldSpoofReplyCameraPresence(stealthMode: StealthMode): Boolean {
|
||||
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofReplyCameraPresence.get()
|
||||
}
|
||||
|
||||
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
|
||||
return context.config.messaging.hideTypingNotifications.get() ||
|
||||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
|
||||
@@ -156,6 +164,8 @@ class Messaging : Feature("Messaging") {
|
||||
|
||||
classReference.getAsClass()?.let { wrapperClass ->
|
||||
val bitmojiMethodNames = mutableSetOf<String>()
|
||||
val viewingGalleryMethodNames = mutableSetOf<String>()
|
||||
val replyCameraMethodNames = mutableSetOf<String>()
|
||||
val typingMethodNames = mutableSetOf<String>()
|
||||
val peekingMethodNames = mutableSetOf<String>()
|
||||
|
||||
@@ -165,14 +175,24 @@ class Messaging : Feature("Messaging") {
|
||||
if (parameterTypes.any { parameterType ->
|
||||
listOf(
|
||||
"PlatformChatVisibleAction",
|
||||
"PlatformChatHiddenAction",
|
||||
"PlatformViewingChatMediaAction",
|
||||
"PlatformUsingReplyCameraAction"
|
||||
"PlatformChatHiddenAction"
|
||||
).any { parameterType.name.contains(it) }
|
||||
}) {
|
||||
bitmojiMethodNames.add(method.name)
|
||||
}
|
||||
|
||||
if (parameterTypes.any { parameterType ->
|
||||
parameterType.name.contains("PlatformViewingChatMediaAction")
|
||||
}) {
|
||||
viewingGalleryMethodNames.add(method.name)
|
||||
}
|
||||
|
||||
if (parameterTypes.any { parameterType ->
|
||||
parameterType.name.contains("PlatformUsingReplyCameraAction")
|
||||
}) {
|
||||
replyCameraMethodNames.add(method.name)
|
||||
}
|
||||
|
||||
if (parameterTypes.any { parameterType ->
|
||||
parameterType.name.contains("PlatformTypingAction")
|
||||
}) {
|
||||
@@ -194,6 +214,22 @@ class Messaging : Feature("Messaging") {
|
||||
}
|
||||
}
|
||||
|
||||
viewingGalleryMethodNames.forEach { methodName ->
|
||||
wrapperClass.hook(methodName, HookStage.BEFORE, {
|
||||
shouldSpoofViewingGalleryPresence(stealthMode)
|
||||
}) {
|
||||
it.setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
replyCameraMethodNames.forEach { methodName ->
|
||||
wrapperClass.hook(methodName, HookStage.BEFORE, {
|
||||
shouldSpoofReplyCameraPresence(stealthMode)
|
||||
}) {
|
||||
it.setResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
typingMethodNames.forEach { methodName ->
|
||||
wrapperClass.hook(methodName, HookStage.BEFORE, {
|
||||
shouldHideTyping(stealthMode, hideTypingIndicator)
|
||||
@@ -214,8 +250,8 @@ class Messaging : Feature("Messaging") {
|
||||
val instance = param.thisObject<Any>()
|
||||
clearField(instance, "PlatformChatVisibleAction", shouldHideBitmojiPresence(stealthMode))
|
||||
clearField(instance, "PlatformChatHiddenAction", shouldHideBitmojiPresence(stealthMode))
|
||||
clearField(instance, "PlatformViewingChatMediaAction", shouldHideBitmojiPresence(stealthMode))
|
||||
clearField(instance, "PlatformUsingReplyCameraAction", shouldHideBitmojiPresence(stealthMode))
|
||||
clearField(instance, "PlatformViewingChatMediaAction", shouldSpoofViewingGalleryPresence(stealthMode))
|
||||
clearField(instance, "PlatformUsingReplyCameraAction", shouldSpoofReplyCameraPresence(stealthMode))
|
||||
clearField(instance, "PlatformTypingAction", shouldHideTyping(stealthMode, hideTypingIndicator))
|
||||
clearField(instance, "PlatformStartPeekingAction", shouldHidePeek(stealthMode))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
class FriendTracker : Feature("Friend Tracker") {
|
||||
companion object {
|
||||
private const val PRESENCE_PEEKING_BIT = 8
|
||||
private const val PRESENCE_REPLY_CAMERA_BIT = 9
|
||||
private const val PRESENCE_CHAT_MEDIA_BIT = 10
|
||||
}
|
||||
|
||||
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
|
||||
private val tracker by lazyBridge { context.bridgeClient.getTracker() }
|
||||
private val translation by lazy { context.translation.getCategory("friend_tracker_notifications") }
|
||||
@@ -37,6 +43,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
))
|
||||
} }
|
||||
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
|
||||
private val galleryEntries = mutableMapOf<Pair<String, String>, Long>()
|
||||
private val replyCameraEntries = mutableMapOf<Pair<String, String>, Long>()
|
||||
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
|
||||
|
||||
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
|
||||
@@ -104,7 +112,12 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
|
||||
context.log.verbose("dispatching $action for $eventType in $conversationName")
|
||||
|
||||
val iCanSeeYouDetails = if (eventType == TrackerEventType.I_CAN_SEE_YOU) buildICanSeeYouDetails(extras) else ""
|
||||
val iCanSeeYouDetails = when (eventType) {
|
||||
TrackerEventType.I_CAN_SEE_YOU,
|
||||
TrackerEventType.I_CAN_SEE_YOU_2,
|
||||
TrackerEventType.I_CAN_SEE_YOU_3 -> buildICanSeeYouDetails(extras)
|
||||
else -> ""
|
||||
}
|
||||
val notificationText = translation[eventType.key]
|
||||
.replace("{friend}", authorName)
|
||||
.replace("{conversation}", conversationName)
|
||||
@@ -133,7 +146,7 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildICanSeeYouExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
|
||||
private fun buildTimedActivityExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
|
||||
entry ?: -1,
|
||||
exit ?: -1,
|
||||
duration ?: -1
|
||||
@@ -189,10 +202,22 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
(currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT
|
||||
oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING
|
||||
oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING
|
||||
(oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING
|
||||
oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING
|
||||
(oldState == null || !oldState.usingReplyCamera) && currentState?.usingReplyCamera == true -> TrackerEventType.STARTED_USING_REPLY_CAMERA
|
||||
oldState?.usingReplyCamera == true && (currentState == null || !currentState.usingReplyCamera) -> TrackerEventType.STOPPED_USING_REPLY_CAMERA
|
||||
(oldState == null || !oldState.viewingChatMedia) && currentState?.viewingChatMedia == true -> TrackerEventType.STARTED_VIEWING_CHAT_MEDIA
|
||||
oldState?.viewingChatMedia == true && (currentState == null || !currentState.viewingChatMedia) -> TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA
|
||||
(oldState == null || !oldState.peeking) &&
|
||||
currentState?.peeking == true &&
|
||||
currentState.usingReplyCamera != true &&
|
||||
oldState?.usingReplyCamera != true -> TrackerEventType.STARTED_PEEKING
|
||||
oldState?.peeking == true &&
|
||||
(currentState == null || !currentState.peeking) &&
|
||||
currentState?.usingReplyCamera != true &&
|
||||
oldState.usingReplyCamera != true -> TrackerEventType.STOPPED_PEEKING
|
||||
else -> null
|
||||
} ?: return
|
||||
}
|
||||
|
||||
eventType ?: return
|
||||
|
||||
when (eventType) {
|
||||
TrackerEventType.CONVERSATION_ENTER -> {
|
||||
@@ -206,7 +231,35 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
TrackerEventType.I_CAN_SEE_YOU,
|
||||
conversationId,
|
||||
userId,
|
||||
buildICanSeeYouExtras(entry, exit, entry?.let { exit - it })
|
||||
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
|
||||
)
|
||||
}
|
||||
TrackerEventType.STARTED_VIEWING_CHAT_MEDIA -> {
|
||||
galleryEntries[conversationId to userId] = System.currentTimeMillis()
|
||||
}
|
||||
TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA -> {
|
||||
val key = conversationId to userId
|
||||
val exit = System.currentTimeMillis()
|
||||
val entry = galleryEntries.remove(key)
|
||||
dispatchEvents(
|
||||
TrackerEventType.I_CAN_SEE_YOU_2,
|
||||
conversationId,
|
||||
userId,
|
||||
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
|
||||
)
|
||||
}
|
||||
TrackerEventType.STARTED_USING_REPLY_CAMERA -> {
|
||||
replyCameraEntries[conversationId to userId] = System.currentTimeMillis()
|
||||
}
|
||||
TrackerEventType.STOPPED_USING_REPLY_CAMERA -> {
|
||||
val key = conversationId to userId
|
||||
val exit = System.currentTimeMillis()
|
||||
val entry = replyCameraEntries.remove(key)
|
||||
dispatchEvents(
|
||||
TrackerEventType.I_CAN_SEE_YOU_3,
|
||||
conversationId,
|
||||
userId,
|
||||
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
@@ -266,14 +319,20 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
userIds.add(participantUserId)
|
||||
if (participantUserId == context.database.myUserId) return@eachBuffer
|
||||
val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer
|
||||
val usingReplyCamera = stateMap.getOrElse(PRESENCE_REPLY_CAMERA_BIT) { false }
|
||||
val viewingChatMedia = stateMap.getOrElse(PRESENCE_CHAT_MEDIA_BIT) { false }
|
||||
val peeking = stateMap.getOrElse(PRESENCE_PEEKING_BIT) { false }
|
||||
|
||||
presenceMap[participantUserId] = FriendPresenceState(
|
||||
bitmojiPresent = stateMap[0],
|
||||
typing = stateMap[4],
|
||||
wasTyping = stateMap[5],
|
||||
speaking = stateMap[6] && stateMap[4],
|
||||
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
|
||||
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
|
||||
// Snapchat moved peeking by one bit on newer builds and added
|
||||
// dedicated chat-presence flags for reply camera and chat media viewing.
|
||||
peeking = peeking,
|
||||
usingReplyCamera = usingReplyCamera,
|
||||
viewingChatMedia = viewingChatMedia
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.annotation.SuppressLint
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaRecorder
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraCharacteristics.Key
|
||||
import android.hardware.camera2.CameraManager
|
||||
@@ -27,6 +29,34 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
override fun init() {
|
||||
val config = context.config.camera
|
||||
|
||||
// Toggle A: Audio & Video Optimizations (Bitrates)
|
||||
if (config.audioVideoOptimizations.get()) {
|
||||
MediaRecorder::class.java.hook("setVideoEncodingBitRate", HookStage.BEFORE) { param ->
|
||||
val currentRate = param.arg<Int>(0)
|
||||
if (currentRate < 30_000_000) param.setArg(0, 30_000_000)
|
||||
}
|
||||
MediaRecorder::class.java.hook("setAudioEncodingBitRate", HookStage.BEFORE) { param ->
|
||||
param.setArg(0, 320_000)
|
||||
}
|
||||
MediaRecorder::class.java.hook("setAudioSamplingRate", HookStage.BEFORE) { param ->
|
||||
param.setArg(0, 48_000)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
|
||||
if (config.cameraOptimizations.get()) {
|
||||
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
|
||||
val key = param.arg<CaptureRequest.Key<*>>(0)
|
||||
when (key) {
|
||||
CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_HIGH_QUALITY)
|
||||
CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY)
|
||||
CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_HIGH_QUALITY)
|
||||
CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_HIGH_QUALITY)
|
||||
CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val frontCameraId by lazy {
|
||||
runCatching { context.androidContext.getSystemService(CameraManager::class.java).run {
|
||||
cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
|
||||
@@ -105,6 +135,18 @@ class CameraTweaks : Feature("Camera Tweaks") {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.unlockZoomLimit.get()) {
|
||||
val maxZoom = config.maxZoomOverride.get().coerceAtLeast(1f)
|
||||
when {
|
||||
key == CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM -> {
|
||||
param.setResult(maxZoom)
|
||||
}
|
||||
key.name == "android.control.zoomRatioRange" -> {
|
||||
param.setResult(Range(1f, maxZoom))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
|
||||
val isFrontCamera = param.invokeOriginal(
|
||||
arrayOf(CameraCharacteristics.LENS_FACING)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.database.impl.ConversationMessage
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.ui.CustomComposable
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.sanitizeForLayout
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class LocalPinnedMessages : Feature("Local Pinned Messages") {
|
||||
data class PinnedMessageSnapshot(
|
||||
val messageId: Long,
|
||||
val senderName: String,
|
||||
val preview: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
private val prefs by lazy {
|
||||
context.androidContext.getSharedPreferences("purrfectsnap_local_pinned_messages", 0)
|
||||
}
|
||||
|
||||
private var revision by mutableLongStateOf(0L)
|
||||
private var trackedChatLayout by mutableStateOf<View?>(null)
|
||||
private var trackedChatVisibility by mutableStateOf(false)
|
||||
|
||||
private fun readPins(): MutableMap<String, PinnedMessageSnapshot> {
|
||||
val json = prefs.getString("pins", null) ?: return mutableMapOf()
|
||||
return runCatching {
|
||||
context.gson.fromJson<MutableMap<String, PinnedMessageSnapshot>>(
|
||||
json,
|
||||
object : TypeToken<MutableMap<String, PinnedMessageSnapshot>>() {}.type
|
||||
) ?: mutableMapOf()
|
||||
}.getOrElse { mutableMapOf() }
|
||||
}
|
||||
|
||||
private fun writePins(pins: Map<String, PinnedMessageSnapshot>) {
|
||||
prefs.edit().putString("pins", context.gson.toJson(pins)).apply()
|
||||
revision++
|
||||
}
|
||||
|
||||
private fun resolveMessagePreview(message: ConversationMessage): String {
|
||||
val messageContainer = message.messageContent?.let { ProtoReader(it) }?.followPath(4, 4)
|
||||
val contentType = ContentType.fromMessageContainer(messageContainer) ?: ContentType.fromId(message.contentType)
|
||||
return messageContainer?.getBuffer()?.getMessageText(contentType)
|
||||
?: "[${context.translation.getCategory("content_type")[contentType.name]}]"
|
||||
}
|
||||
|
||||
private fun resolveSenderName(message: ConversationMessage): String {
|
||||
return message.senderId?.let { senderId ->
|
||||
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername }
|
||||
} ?: context.translation.getCategory("logger_history")["unknown_sender"]
|
||||
}
|
||||
|
||||
fun pinFocusedMessage() {
|
||||
val messaging = context.feature(Messaging::class)
|
||||
val conversationId = messaging.openedConversationUUID?.toString() ?: return
|
||||
val messageId = messaging.lastFocusedMessageId.takeIf { it > 0 } ?: return
|
||||
val message = context.database.getConversationMessageFromId(messageId) ?: return
|
||||
|
||||
val pins = readPins()
|
||||
pins[conversationId] = PinnedMessageSnapshot(
|
||||
messageId = messageId,
|
||||
senderName = resolveSenderName(message),
|
||||
preview = resolveMessagePreview(message).sanitizeForLayout(),
|
||||
timestamp = message.creationTimestamp
|
||||
)
|
||||
writePins(pins)
|
||||
context.shortToast(context.translation["local_pinned_messages.pinned_toast"])
|
||||
}
|
||||
|
||||
fun unpinFocusedConversation() {
|
||||
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return
|
||||
val pins = readPins()
|
||||
if (pins.remove(conversationId) != null) {
|
||||
writePins(pins)
|
||||
context.shortToast(context.translation["local_pinned_messages.unpinned_toast"])
|
||||
}
|
||||
}
|
||||
|
||||
fun hasPinnedMessageForOpenedConversation(): Boolean {
|
||||
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return false
|
||||
return readPins().containsKey(conversationId)
|
||||
}
|
||||
|
||||
private fun isActuallyVisible(view: View): Boolean {
|
||||
if (!view.isShown || view.visibility != View.VISIBLE || !view.isAttachedToWindow) return false
|
||||
if (view.width <= 0 || view.height <= 0 || view.alpha <= 0f) return false
|
||||
return view.getGlobalVisibleRect(Rect())
|
||||
}
|
||||
|
||||
private fun trackChatLayoutCandidate(view: View?) {
|
||||
view ?: return
|
||||
trackedChatLayout = view
|
||||
trackedChatVisibility = isActuallyVisible(view)
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
when {
|
||||
event.parent.javaClass.name.endsWith("ChatInputLayout") -> {
|
||||
trackChatLayoutCandidate(event.parent)
|
||||
}
|
||||
event.viewClassName.endsWith("ChatInputLayout") -> {
|
||||
trackChatLayoutCandidate(event.view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
trackedChatLayout = null
|
||||
trackedChatVisibility = false
|
||||
}
|
||||
|
||||
lateinit var pinnedComposable: CustomComposable
|
||||
pinnedComposable = {
|
||||
revision
|
||||
val trackedLayout = trackedChatLayout
|
||||
var currentConversationId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(revision, trackedLayout) {
|
||||
while (true) {
|
||||
currentConversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
trackedChatVisibility = trackedLayout?.let { isActuallyVisible(it) } == true
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
val conversationId = currentConversationId
|
||||
if (conversationId != null && trackedChatVisibility) {
|
||||
val pinned = remember(revision, conversationId) { readPins()[conversationId] }
|
||||
if (pinned != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 62.dp, start = 14.dp, end = 14.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
PurrfectOverlayTheme {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.95f), shape)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), shape)
|
||||
.clickable { }
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.PushPin,
|
||||
contentDescription = null,
|
||||
tint = PurrfectOverlayPalette.glowPrimary
|
||||
)
|
||||
Text(
|
||||
text = pinned.preview,
|
||||
color = Color.White.copy(alpha = 0.95f),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier.clickable {
|
||||
unpinFocusedConversation()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.inAppOverlay.addCustomComposable(pinnedComposable)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,11 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@ class OperaStoryOverlayState {
|
||||
val totalCountState = mutableIntStateOf(0)
|
||||
val snapSourceState = mutableStateOf<String?>(null)
|
||||
val isInConversationState = mutableStateOf(false)
|
||||
val storyIdentityState = mutableStateOf<String?>(null)
|
||||
|
||||
fun setupDisplayStateHook(
|
||||
context: ModContext,
|
||||
@@ -63,6 +64,14 @@ class OperaStoryOverlayState {
|
||||
?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull()
|
||||
val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull()
|
||||
?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull()
|
||||
val storyIdentity = mediaParamMap["STORY_ID"]?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
?: mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
|
||||
?.substringAfter("storyUserId=", "")
|
||||
?.substringBefore(",")
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
var mediaOrigin = ""
|
||||
if (showSourceIndicator) {
|
||||
@@ -81,6 +90,7 @@ class OperaStoryOverlayState {
|
||||
totalCountState.intValue = totalCount ?: 0
|
||||
snapSourceState.value = snapSource
|
||||
isInConversationState.value = false
|
||||
storyIdentityState.value = storyIdentity
|
||||
|
||||
onSnapFullyDisplayed?.let { callback ->
|
||||
if (currentIndex != null) callback(currentIndex)
|
||||
|
||||
@@ -26,6 +26,8 @@ class OperaStorySnapJump(
|
||||
private var retryRunnable: Runnable? = null
|
||||
private var nextTapRunnable: Runnable? = null
|
||||
private var lastHandledIndex = -1
|
||||
private var jumpOriginStoryIdentity: String? = null
|
||||
private var jumpOriginTotalCount: Int = 0
|
||||
|
||||
fun simulateTap(forward: Boolean) {
|
||||
val activity = context.mainActivity ?: return
|
||||
@@ -83,6 +85,8 @@ class OperaStorySnapJump(
|
||||
isJumping = false
|
||||
jumpTargetIndex = -1
|
||||
lastHandledIndex = -1
|
||||
jumpOriginStoryIdentity = null
|
||||
jumpOriginTotalCount = 0
|
||||
mainHandler.postDelayed({
|
||||
val overlay = storyFrameLayout()?.findViewWithTag<View>("jump_overlay") ?: return@postDelayed
|
||||
overlay.animate()
|
||||
@@ -99,6 +103,14 @@ class OperaStorySnapJump(
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
val forward = jumpTargetIndex > fromIndex
|
||||
simulateTap(forward)
|
||||
@@ -114,6 +126,14 @@ class OperaStorySnapJump(
|
||||
val retry = Runnable {
|
||||
if (!isJumping || gen != jumpGeneration) return@Runnable
|
||||
val currentIdx = overlayState.currentIndexState.intValue
|
||||
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
|
||||
removeJumpOverlay()
|
||||
return@Runnable
|
||||
}
|
||||
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
|
||||
removeJumpOverlay()
|
||||
return@Runnable
|
||||
}
|
||||
if (currentIdx == fromIndex) {
|
||||
if (retryCount >= maxRetries) {
|
||||
removeJumpOverlay()
|
||||
@@ -129,6 +149,14 @@ class OperaStorySnapJump(
|
||||
fun onSnapFullyDisplayed(currentIndex: Int) {
|
||||
if (!isJumping || jumpTargetIndex < 0) return
|
||||
if (currentIndex == lastHandledIndex) return
|
||||
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingRetry()
|
||||
cancelPendingNextTap()
|
||||
@@ -138,6 +166,18 @@ class OperaStorySnapJump(
|
||||
return
|
||||
}
|
||||
|
||||
if (lastHandledIndex >= 0) {
|
||||
val expectedForward = jumpTargetIndex > lastHandledIndex
|
||||
if (expectedForward && currentIndex < lastHandledIndex) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
if (!expectedForward && currentIndex > lastHandledIndex) {
|
||||
removeJumpOverlay()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lastHandledIndex = currentIndex
|
||||
val gen = jumpGeneration
|
||||
val tapRunnable = Runnable {
|
||||
@@ -179,6 +219,8 @@ class OperaStorySnapJump(
|
||||
jumpTargetIndex = targetIndex
|
||||
lastHandledIndex = -1
|
||||
isJumping = true
|
||||
jumpOriginStoryIdentity = overlayState.storyIdentityState.value
|
||||
jumpOriginTotalCount = overlayState.totalCountState.intValue
|
||||
|
||||
showJumpOverlay()
|
||||
|
||||
|
||||
@@ -117,4 +117,8 @@ class CoreMessagingBridge(
|
||||
}
|
||||
|
||||
override fun getOneToOneConversationId(userId: String) = context.database.getDMConversationId(userId)
|
||||
|
||||
override fun getAutoOpenInterface(): me.eternal.purrfectsnap.bridge.AutoOpenInterface? {
|
||||
return context.feature(me.eternal.purrfectsnap.core.features.impl.experiments.AutoOpenSnaps::class).getInterface()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
|
||||
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
|
||||
import me.eternal.purrfectsnap.core.ui.ViewTagState
|
||||
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
|
||||
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
|
||||
@@ -212,7 +213,24 @@ class ChatActionMenu : AbstractMenu() {
|
||||
})
|
||||
}
|
||||
|
||||
val pinnedMessages = context.feature(LocalPinnedMessages::class)
|
||||
injectButton(Button(viewGroup.context).apply {
|
||||
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
|
||||
this@ChatActionMenu.context.translation["chat_action_menu.unpin_local_message"]
|
||||
} else {
|
||||
this@ChatActionMenu.context.translation["chat_action_menu.pin_local_message"]
|
||||
}
|
||||
setOnClickListener {
|
||||
closeActionMenu()
|
||||
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
|
||||
pinnedMessages.unpinFocusedConversation()
|
||||
} else {
|
||||
pinnedMessages.pinFocusedMessage()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
viewGroup.addView(buttonContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDeco
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
|
||||
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.debugEditText
|
||||
import me.eternal.purrfectsnap.core.ui.iterateParent
|
||||
@@ -343,6 +344,20 @@ class NewChatActionMenu : AbstractMenu() {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
val pinnedMessages = context.feature(LocalPinnedMessages::class)
|
||||
ListButton(
|
||||
icon = Icons.Outlined.PushPin,
|
||||
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) context.translation["chat_action_menu.unpin_local_message"] else context.translation["chat_action_menu.pin_local_message"],
|
||||
modifier = Modifier.clickable {
|
||||
closeActionMenu()
|
||||
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
|
||||
pinnedMessages.unpinFocusedConversation()
|
||||
} else {
|
||||
pinnedMessages.pinFocusedMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
|
||||
@@ -19,7 +19,36 @@ class MediaInfo(obj: Any?) : AbstractWrapper(obj) {
|
||||
if (it.isEmpty()) {
|
||||
throw RuntimeException("MediaInfo is empty")
|
||||
}
|
||||
instance = it[0]!!
|
||||
|
||||
// Select highest quality media by comparing width * height
|
||||
// Use explicit field name search to avoid relying on field order
|
||||
instance = it.filterNotNull().maxByOrNull { mediaObj ->
|
||||
runCatching {
|
||||
val fields = mediaObj.javaClass.fields
|
||||
|
||||
// Search for width and height fields by name (case-insensitive)
|
||||
// Common patterns: "width", "mWidth", "height", "mHeight"
|
||||
val widthField = fields.find { f ->
|
||||
f.name.equals("width", ignoreCase = true) ||
|
||||
f.name.equals("mWidth", ignoreCase = true)
|
||||
}
|
||||
val heightField = fields.find { f ->
|
||||
f.name.equals("height", ignoreCase = true) ||
|
||||
f.name.equals("mHeight", ignoreCase = true)
|
||||
}
|
||||
|
||||
// Validate fields exist and are integers before calculating resolution
|
||||
if (widthField != null && heightField != null &&
|
||||
(widthField.type == Int::class.javaPrimitiveType || widthField.type == Int::class.java) &&
|
||||
(heightField.type == Int::class.javaPrimitiveType || heightField.type == Int::class.java)) {
|
||||
widthField.isAccessible = true
|
||||
heightField.isAccessible = true
|
||||
widthField.getInt(mediaObj) * heightField.getInt(mediaObj)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}.getOrDefault(0)
|
||||
} ?: it.filterNotNull().firstOrNull() ?: it.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.5.3
|
||||
APP_VERSION_CODE=298
|
||||
APP_VERSION_NAME=1.6.2
|
||||
APP_VERSION_CODE=314
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
@@ -1,31 +1,70 @@
|
||||
use std::{ffi::{CStr, CString}, fs};
|
||||
use std::{cell::Cell, ffi::{CStr, CString}};
|
||||
|
||||
use nix::libc::{self, c_uint};
|
||||
|
||||
use crate::{config, def_hook, dobby_hook_sym};
|
||||
|
||||
thread_local! {
|
||||
static FONT_REDIRECT_IN_PROGRESS: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
fn should_redirect_font(pathname: &str) -> bool {
|
||||
let normalized = pathname.replace('\\', "/");
|
||||
let file_name = normalized.rsplit('/').next().unwrap_or(&normalized).to_ascii_lowercase();
|
||||
let is_font_file = file_name.ends_with(".ttf")
|
||||
|| file_name.ends_with(".ttc")
|
||||
|| file_name.ends_with(".otf");
|
||||
let is_system_font_path = normalized.starts_with("/system/fonts/")
|
||||
|| normalized.starts_with("/product/fonts/")
|
||||
|| normalized.starts_with("/system_ext/fonts/")
|
||||
|| normalized.starts_with("/vendor/fonts/");
|
||||
|
||||
is_system_font_path && is_font_file && (
|
||||
file_name.contains("emoji")
|
||||
|| file_name == "noto_color_emoji.ttf"
|
||||
|| file_name == "samsungcoloremoji.ttf"
|
||||
)
|
||||
}
|
||||
|
||||
fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
|
||||
let font_path = config::native_config().custom_emoji_font_path.clone()?;
|
||||
|
||||
match CString::new(font_path.clone()) {
|
||||
Ok(c_font_path) => {
|
||||
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
|
||||
let was_active = guard.replace(true);
|
||||
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) };
|
||||
guard.set(was_active);
|
||||
fd
|
||||
});
|
||||
if fd >= 0 {
|
||||
debug!("redirected emoji font open to {}", font_path);
|
||||
Some(fd)
|
||||
} else {
|
||||
debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("custom emoji font path contains null byte, using fallback system font");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def_hook!(
|
||||
open_hook,
|
||||
i32,
|
||||
|path: *const u8, flags: i32, mode: c_uint| {
|
||||
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
|
||||
if pathname == "/system/fonts/NotoColorEmoji.ttf" {
|
||||
if let Some(font_path) = config::native_config().custom_emoji_font_path {
|
||||
if fs::metadata(&font_path).is_ok() {
|
||||
match CString::new(font_path.clone()) {
|
||||
Ok(c_font_path) => {
|
||||
let fd = libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode);
|
||||
if fd >= 0 {
|
||||
return fd;
|
||||
}
|
||||
warn!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("custom emoji font path contains null byte, using fallback system font");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("custom emoji font path does not exist: {}", font_path);
|
||||
if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) {
|
||||
return open_hook_original.unwrap()(path, flags, mode);
|
||||
}
|
||||
|
||||
if !path.is_null() {
|
||||
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
|
||||
if should_redirect_font(pathname) {
|
||||
if let Some(fd) = open_custom_font_fd(flags, mode) {
|
||||
return fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +74,6 @@ def_hook!(
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
pub fn init() {
|
||||
if config::native_config().custom_emoji_font_path.is_none() {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user