Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82db6a863 | ||
|
|
9781008b2e | ||
|
|
c71e06095d | ||
|
|
1fdaf6d55d | ||
|
|
8a9258b318 | ||
|
|
6491288513 | ||
|
|
33664c1112 | ||
|
|
bb8e644b4c | ||
|
|
21fba253b1 | ||
|
|
9e5b318120 | ||
|
|
00c5d60b7b | ||
|
|
21cd306132 | ||
|
|
0d42aed0ff | ||
|
|
f8fdd1893f | ||
|
|
a8e2148b26 | ||
|
|
1e9ad8eb2b | ||
|
|
182e7eefeb | ||
|
|
070a8ffaf7 | ||
|
|
95f98221e3 | ||
|
|
dbbc52b67f | ||
|
|
b7c8042a93 | ||
|
|
141b5e16a0 | ||
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 | ||
|
|
7a8ad81d48 | ||
|
|
7db60d6eb1 | ||
|
|
565a8b8287 | ||
|
|
1c612ebc8e | ||
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 | ||
|
|
58c4be44f2 | ||
|
|
3d83c3116c | ||
|
|
2b00898355 | ||
|
|
975a9a101f | ||
|
|
793659c63b | ||
|
|
5bdc1d7f59 | ||
|
|
fc66453167 | ||
|
|
84a9d01d6a | ||
|
|
a4b948bba3 | ||
|
|
8c720992de | ||
|
|
d1fc97cee8 | ||
|
|
9df49f1bff | ||
|
|
652062769e | ||
|
|
f1eb833655 | ||
|
|
a27753d8ae | ||
|
|
da8a261202 | ||
|
|
532ebfe0a7 | ||
|
|
bf7f371022 | ||
|
|
15c9a3fd5d | ||
|
|
bb0ab20a5b | ||
|
|
9a507684ee | ||
|
|
1fbf82b4fb | ||
|
|
53696c26f4 | ||
|
|
381b190535 | ||
|
|
50199b052f | ||
|
|
2135d66124 | ||
|
|
6948d86efc | ||
|
|
481e5840f0 | ||
|
|
216bb71fef | ||
|
|
181d19424f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
@@ -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()
|
||||
|
||||
@@ -30,11 +30,21 @@ class BridgeService : Service() {
|
||||
private lateinit var remoteSideContext: RemoteSideContext
|
||||
private var syncCallback: SyncCallback? = null
|
||||
var messagingBridge: MessagingBridge? = null
|
||||
@Volatile
|
||||
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
|
||||
|
||||
private fun clearSyncCallback() {
|
||||
syncCallback = null
|
||||
}
|
||||
|
||||
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
|
||||
pendingSocialSnapshotCallback = callback
|
||||
}
|
||||
|
||||
fun clearEphemeralSocialSnapshotRequest() {
|
||||
pendingSocialSnapshotCallback = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
clearSyncCallback()
|
||||
if (::remoteSideContext.isInitialized) {
|
||||
@@ -216,6 +226,11 @@ class BridgeService : Service() {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(parsedFriends, parsedGroups)
|
||||
return
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.google.gson.GsonBuilder
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
@@ -62,6 +60,10 @@ class DownloadProcessor (
|
||||
private val remoteSideContext: RemoteSideContext,
|
||||
private val callback: DownloadCallback
|
||||
) {
|
||||
companion object {
|
||||
private val downloadSemaphore = Semaphore(3)
|
||||
}
|
||||
|
||||
private data class GallerySaveResult(
|
||||
val uri: Uri,
|
||||
val alreadyDownloaded: Boolean = false
|
||||
@@ -168,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? {
|
||||
@@ -310,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(
|
||||
@@ -344,7 +318,6 @@ class DownloadProcessor (
|
||||
val subPath = sanitizeRelativePath(
|
||||
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
)
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
@@ -354,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,8 +393,7 @@ class DownloadProcessor (
|
||||
return File.createTempFile("media", ".tmp")
|
||||
}
|
||||
|
||||
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
|
||||
val jobs = mutableListOf<Job>()
|
||||
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
|
||||
val downloadedMedias = mutableMapOf<InputMedia, File>()
|
||||
var totalSize = 1L
|
||||
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
|
||||
@@ -455,71 +406,72 @@ class DownloadProcessor (
|
||||
)
|
||||
}
|
||||
|
||||
downloadRequest.inputMedias.forEach { inputMedia ->
|
||||
fun setProgress(progress: String) {
|
||||
inputMediaProgress[inputMedia] = progress
|
||||
updateDownloadProgress()
|
||||
}
|
||||
coroutineScope {
|
||||
downloadRequest.inputMedias.forEach { inputMedia ->
|
||||
fun setProgress(progress: String) {
|
||||
inputMediaProgress[inputMedia] = progress
|
||||
updateDownloadProgress()
|
||||
}
|
||||
|
||||
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
|
||||
createMediaTempFile().apply {
|
||||
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
|
||||
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
|
||||
var read: Int
|
||||
var totalRead = 0L
|
||||
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
|
||||
createMediaTempFile().apply {
|
||||
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
|
||||
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
|
||||
var read: Int
|
||||
var totalRead = 0L
|
||||
|
||||
outputStream().use { outputStream ->
|
||||
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
inputMediaDownloadedBytes[inputMedia] = totalRead
|
||||
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
|
||||
}
|
||||
}
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
|
||||
launch {
|
||||
when (inputMedia.type) {
|
||||
DownloadMediaType.PROTO_MEDIA -> {
|
||||
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
|
||||
totalSize += length
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = length)
|
||||
}
|
||||
})
|
||||
}
|
||||
DownloadMediaType.REMOTE_MEDIA -> {
|
||||
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connect()
|
||||
totalSize += contentLength.toLong()
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = contentLength.toLong())
|
||||
outputStream().use { outputStream ->
|
||||
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
inputMediaDownloadedBytes[inputMedia] = totalRead
|
||||
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadMediaType.DIRECT_MEDIA -> {
|
||||
val decoded = Base64.UrlSafe.decode(inputMedia.content)
|
||||
totalSize += decoded.size.toLong()
|
||||
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
|
||||
}
|
||||
else -> {
|
||||
File(inputMedia.content).inputStream().use {
|
||||
totalSize += it.available().toLong()
|
||||
handleInputStream(it, estimatedSize = it.available().toLong())
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
|
||||
launch {
|
||||
when (inputMedia.type) {
|
||||
DownloadMediaType.PROTO_MEDIA -> {
|
||||
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
|
||||
totalSize += length
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = length)
|
||||
}
|
||||
})
|
||||
}
|
||||
DownloadMediaType.REMOTE_MEDIA -> {
|
||||
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connect()
|
||||
totalSize += contentLength.toLong()
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = contentLength.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadMediaType.DIRECT_MEDIA -> {
|
||||
val decoded = Base64.UrlSafe.decode(inputMedia.content)
|
||||
totalSize += decoded.size.toLong()
|
||||
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
|
||||
}
|
||||
else -> {
|
||||
File(inputMedia.content).inputStream().use {
|
||||
totalSize += it.available().toLong()
|
||||
handleInputStream(it, estimatedSize = it.available().toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also { jobs.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
downloadedMedias
|
||||
return downloadedMedias
|
||||
}
|
||||
|
||||
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
|
||||
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
|
||||
downloadRequest.inputMedias.first().let { inputMedia ->
|
||||
val mediaType = inputMedia.type
|
||||
val media = downloadedMedias[inputMedia]!!
|
||||
@@ -530,34 +482,37 @@ class DownloadProcessor (
|
||||
val outputFile = File.createTempFile("voice_note", ".$format")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.CONVERSION,
|
||||
inputs = listOf(media.file.absolutePath),
|
||||
inputs = listOf(media.absolutePath),
|
||||
output = outputFile
|
||||
))
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
saveMediaToGallery(pendingTask, outputFile, metadata)
|
||||
outputFile.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saveMediaToGallery(pendingTask, media.file, metadata)
|
||||
media.file.delete()
|
||||
saveMediaToGallery(pendingTask, media, metadata)
|
||||
media.delete()
|
||||
return
|
||||
}
|
||||
|
||||
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
|
||||
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
|
||||
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
|
||||
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!!
|
||||
|
||||
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
|
||||
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
|
||||
dashPlaylistFile.outputStream().use {
|
||||
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
|
||||
}
|
||||
@@ -582,7 +537,7 @@ class DownloadProcessor (
|
||||
|
||||
dashPlaylistFile.delete()
|
||||
outputFile.delete()
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +557,7 @@ class DownloadProcessor (
|
||||
// check if the media file has been deleted
|
||||
if (task.type == TaskType.DOWNLOAD) {
|
||||
val outputFile = runCatching {
|
||||
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
|
||||
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
|
||||
}.getOrNull()
|
||||
|
||||
if (outputFile != null && !outputFile.exists()) {
|
||||
@@ -617,113 +572,115 @@ class DownloadProcessor (
|
||||
return@launch
|
||||
}
|
||||
|
||||
callbackOnProgress(translation["download_started_toast"])
|
||||
remoteSideContext.log.debug("downloading media")
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = downloadMetadata.downloadSource,
|
||||
author = downloadMetadata.mediaAuthor,
|
||||
hash = downloadMetadata.mediaIdentifier
|
||||
)
|
||||
).apply {
|
||||
status = TaskStatus.RUNNING
|
||||
addListener(PendingTaskListener(onCancel = {
|
||||
coroutineContext.job.cancel()
|
||||
}))
|
||||
updateProgress("Downloading...")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
if (downloadRequest.isAudioStream) {
|
||||
val streamUrl = downloadRequest.inputMedias.first().content
|
||||
val outputFile = File.createTempFile("audio_stream", ".mp3")
|
||||
|
||||
callbackOnProgress("Downloading audio stream")
|
||||
pendingTask.updateProgress("Downloading audio stream")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf(streamUrl),
|
||||
output = outputFile,
|
||||
audioStreamFormat = downloadRequest.audioStreamFormat
|
||||
))
|
||||
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
|
||||
return@launch
|
||||
downloadSemaphore.withPermit {
|
||||
callbackOnProgress(translation["download_started_toast"])
|
||||
remoteSideContext.log.debug("downloading media")
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = downloadMetadata.downloadSource,
|
||||
author = downloadMetadata.mediaAuthor,
|
||||
hash = downloadMetadata.mediaIdentifier
|
||||
)
|
||||
).apply {
|
||||
status = TaskStatus.RUNNING
|
||||
addListener(PendingTaskListener(onCancel = {
|
||||
coroutineContext.job.cancel()
|
||||
}))
|
||||
updateProgress("Downloading...")
|
||||
}
|
||||
|
||||
//first download all input medias into cache
|
||||
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
|
||||
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
|
||||
}.toMap().toMutableMap()
|
||||
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
|
||||
runCatching {
|
||||
if (downloadRequest.isAudioStream) {
|
||||
val streamUrl = downloadRequest.inputMedias.first().content
|
||||
val outputFile = File.createTempFile("audio_stream", ".mp3")
|
||||
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
callbackOnProgress("Downloading audio stream")
|
||||
pendingTask.updateProgress("Downloading audio stream")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf(streamUrl),
|
||||
output = outputFile,
|
||||
audioStreamFormat = downloadRequest.audioStreamFormat
|
||||
))
|
||||
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
|
||||
return@launch
|
||||
}
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
downloadedMedias.clear()
|
||||
//first download all input medias into cache
|
||||
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
|
||||
it.key to it.value
|
||||
}.toMap().toMutableMap()
|
||||
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
|
||||
|
||||
zipFile.file.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
createMediaTempFile().apply {
|
||||
outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
downloadedMedias.clear()
|
||||
|
||||
zipFile.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
createMediaTempFile().apply {
|
||||
outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = it
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = DownloadedFile(it, FileType.fromFile(it))
|
||||
}
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) ->
|
||||
value.delete()
|
||||
}
|
||||
|
||||
shouldMergeOverlay = true
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) ->
|
||||
value.file.delete()
|
||||
if (shouldMergeOverlay) {
|
||||
assert(downloadedMedias.size == 2)
|
||||
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
|
||||
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
|
||||
|
||||
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
|
||||
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
|
||||
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_OVERLAY,
|
||||
inputs = listOf(renamedMedia.absolutePath),
|
||||
output = mergedOverlay,
|
||||
overlay = renamedOverlayMedia
|
||||
))
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
remoteSideContext.log.error("Failed to merge overlay", exception)
|
||||
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
|
||||
pendingTask.fail("Failed to merge overlay")
|
||||
}
|
||||
|
||||
mergedOverlay.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}
|
||||
|
||||
shouldMergeOverlay = true
|
||||
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
|
||||
}.onFailure { exception ->
|
||||
pendingTask.fail("Failed to download media")
|
||||
remoteSideContext.log.error("Failed to download media", exception)
|
||||
callbackOnFailure(translation["failed_generic_toast"], exception.message)
|
||||
}
|
||||
|
||||
if (shouldMergeOverlay) {
|
||||
assert(downloadedMedias.size == 2)
|
||||
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
|
||||
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
|
||||
|
||||
val renamedMedia = renameFromFileType(media.file, media.fileType)
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
|
||||
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
|
||||
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_OVERLAY,
|
||||
inputs = listOf(renamedMedia.absolutePath),
|
||||
output = mergedOverlay,
|
||||
overlay = renamedOverlayMedia
|
||||
))
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
remoteSideContext.log.error("Failed to merge overlay", exception)
|
||||
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
|
||||
pendingTask.fail("Failed to merge overlay")
|
||||
}
|
||||
|
||||
mergedOverlay.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}
|
||||
|
||||
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
|
||||
}.onFailure { exception ->
|
||||
pendingTask.fail("Failed to download media")
|
||||
remoteSideContext.log.error("Failed to download media", exception)
|
||||
callbackOnFailure(translation["failed_generic_toast"], exception.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,18 @@ class FFMpegProcessor(
|
||||
if (session.returnCode.isValueSuccess) {
|
||||
Result.success(session)
|
||||
} else {
|
||||
Result.failure(Exception(session.output))
|
||||
val output = session.output
|
||||
val errorMsg = when {
|
||||
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
|
||||
else -> {
|
||||
val lines = output.lines().filter { line ->
|
||||
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
|
||||
}
|
||||
lines.lastOrNull()?.take(400)
|
||||
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
|
||||
}
|
||||
}
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
)
|
||||
}, logFunction@{ log ->
|
||||
@@ -119,11 +130,6 @@ class FFMpegProcessor(
|
||||
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
|
||||
}
|
||||
|
||||
private fun isMediaCodecFailure(output: String): Boolean {
|
||||
val lower = output.lowercase()
|
||||
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
|
||||
}
|
||||
|
||||
suspend fun execute(args: Request) {
|
||||
// load ffmpeg native sync to avoid native crash
|
||||
synchronized(this) { FFmpegKit.listSessions() }
|
||||
@@ -140,7 +146,7 @@ class FFMpegProcessor(
|
||||
|
||||
val outputArguments = ArgumentList().apply {
|
||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
|
||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
@@ -215,7 +221,7 @@ class FFMpegProcessor(
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-map" to "\"[vout]\""
|
||||
|
||||
@@ -232,7 +238,6 @@ class FFMpegProcessor(
|
||||
}
|
||||
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
||||
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
||||
outputArguments += "-c:a" to "pcm_s16le"
|
||||
}
|
||||
Action.MERGE_AUDIO_STREAMS -> {
|
||||
inputArguments.clear()
|
||||
@@ -241,40 +246,21 @@ class FFMpegProcessor(
|
||||
args.inputs.forEachIndexed { index, input ->
|
||||
inputArguments += "-i" to input
|
||||
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
||||
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
|
||||
if (offset > 0) {
|
||||
filterParts.append(",adelay=$offset|$offset[a$index];")
|
||||
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
|
||||
} else {
|
||||
filterParts.append(",acopy[a$index];")
|
||||
filterParts.append("[$index:a]acopy[a$index];")
|
||||
}
|
||||
}
|
||||
args.inputs.indices.forEach { index ->
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-c:a" to "libmp3lame"
|
||||
outputArguments += "-b:a" to "192k"
|
||||
outputArguments += "-ar" to "48000"
|
||||
outputArguments += "-ac" to "2"
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
try {
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
} catch (e: Exception) {
|
||||
val output = e.message.orEmpty()
|
||||
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
|
||||
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
|
||||
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
|
||||
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to "libx264"
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -127,15 +129,26 @@ class TaskManager(
|
||||
|
||||
fun getTaskByHash(hash: String?): Task? {
|
||||
if (hash == null) return null
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
return readTaskFromCursor(cursor)
|
||||
return runBlocking {
|
||||
suspendCoroutine { continuation ->
|
||||
queueExecutor.execute {
|
||||
runCatching {
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
|
||||
} else {
|
||||
continuation.resumeWith(Result.success(null))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
continuation.resumeWith(Result.failure(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getActiveTasks() = activeTasks
|
||||
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
|
||||
|
||||
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
|
||||
val tasks = mutableMapOf<Long, Task>()
|
||||
|
||||
@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.ThemePreferences
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
|
||||
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
|
||||
import android.content.IntentFilter
|
||||
|
||||
@@ -212,6 +213,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
navigation.NavContent(contentPadding, startDestination)
|
||||
|
||||
// Theme Reveal Overlay (Android 13+ only for stability)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
|
||||
CircularRevealOverlay(
|
||||
context = managerContext,
|
||||
request = revealRequest,
|
||||
onComplete = { navigation.themeRevealState.clearReveal() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Instantly clear reveal state on older versions
|
||||
navigation.themeRevealState.pendingReveal?.let {
|
||||
navigation.themeRevealState.clearReveal()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
|
||||
@@ -104,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.navigation
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
|
||||
import kotlin.math.round
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
@@ -122,6 +123,7 @@ class Navigation(
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
var globalScrollOffset by mutableIntStateOf(0)
|
||||
val themeRevealState = ThemeRevealState()
|
||||
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.zIndex
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
@@ -49,6 +50,10 @@ fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Floating Top Bar for Aphelion.
|
||||
* Handles the signature morphing animation and provides a "Bottom Content" slot.
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingTopBar(
|
||||
title: String,
|
||||
@@ -56,16 +61,21 @@ fun FloatingTopBar(
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollOffset: Int = 0,
|
||||
enableMorph: Boolean = false,
|
||||
containerAlpha: Float = 1f,
|
||||
titleAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val focusFactor by remember(scrollOffset) {
|
||||
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
|
||||
val focusFactor by remember(scrollOffset, enableMorph) {
|
||||
derivedStateOf {
|
||||
if (!enableMorph) 0f
|
||||
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val morphingParams by remember(focusFactor, statusBarHeight) {
|
||||
@@ -88,10 +98,10 @@ fun FloatingTopBar(
|
||||
|
||||
var hasSnapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(focusFactor) {
|
||||
if (focusFactor >= 1f && !hasSnapped) {
|
||||
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasSnapped = true
|
||||
} else if (focusFactor < 0.9f) {
|
||||
} else if (focusFactor < 0.5f) {
|
||||
hasSnapped = false
|
||||
}
|
||||
}
|
||||
@@ -115,7 +125,7 @@ fun FloatingTopBar(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
@@ -183,87 +193,91 @@ fun FloatingTopBar(
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
|
||||
bottomContent(focusFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@ class HomeAbout : Routes.Route() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"])
|
||||
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
|
||||
}
|
||||
|
||||
key(themeId) {
|
||||
|
||||
@@ -254,7 +254,7 @@ class HomeLogs : Routes.Route() {
|
||||
tint = PurrfectPalette.glowPrimary
|
||||
)
|
||||
},
|
||||
text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) },
|
||||
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onExport()
|
||||
showMenu = false
|
||||
@@ -268,7 +268,7 @@ class HomeLogs : Routes.Route() {
|
||||
tint = Color(0xFFFF9CAB)
|
||||
)
|
||||
},
|
||||
text = { Text(text = translation["clear_button"] ?: "Clear", color = Color.White) },
|
||||
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onClear()
|
||||
showMenu = false
|
||||
|
||||
@@ -776,3 +776,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
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.Column
|
||||
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
@@ -155,7 +150,6 @@ class RetroGameScreen : Routes.Route() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"]?:"")
|
||||
resetGame()
|
||||
while (true) {
|
||||
delay(16)
|
||||
@@ -197,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -208,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
|
||||
.padding(bottom = bottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
if (isAphelion) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["title"] ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -899,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,35 +219,80 @@ class AddFriendDialog(
|
||||
var hasFetchError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
fun applySnapshot(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else friends
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to send broadcast", it)
|
||||
hasFetchError = true
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = {
|
||||
|
||||
@@ -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
|
||||
@@ -568,3 +560,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
|
||||
val horizontalPadding = 24.dp
|
||||
val horizontalPadding = 24.dp
|
||||
val bottomPadding = routes.bottomPadding
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
@@ -74,10 +74,12 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = controlsHeight, bottom = bottomPadding + 4.dp),
|
||||
.padding(bottom = bottomPadding + 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(controlsHeight))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
@@ -111,9 +113,6 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
|
||||
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
|
||||
}
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
@@ -216,7 +215,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
@@ -231,6 +230,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = scrollState.value,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
|
||||
import me.eternal.purrfectsnap.common.config.PropertyKey
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.toPropertyPair
|
||||
|
||||
@Composable
|
||||
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
@@ -36,7 +35,7 @@ fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
|
||||
}.map { (it.key to it.value).toPropertyPair() }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
|
||||
@@ -462,6 +462,14 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var changelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
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) }
|
||||
@@ -488,6 +496,33 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChangelog() {
|
||||
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
|
||||
if (changelogVersion == targetVersion && changelogText != null) return
|
||||
changelogLoading = true
|
||||
changelogError = 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")
|
||||
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogText = text
|
||||
changelogVersion = targetVersion
|
||||
changelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogError = e.message ?: "Failed to fetch"
|
||||
changelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAnnouncements() {
|
||||
if (announcementsText != null) return
|
||||
announcementsLoading = true
|
||||
@@ -500,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() }
|
||||
|
||||
@@ -595,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,
|
||||
@@ -603,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(
|
||||
@@ -624,7 +691,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
latestUpdate = latestUpdate,
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true } },
|
||||
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onAboutClick = { routes.about.navigate() },
|
||||
@@ -747,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)
|
||||
@@ -759,13 +827,42 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
if (showChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "",
|
||||
text = translation["changelog_dialog_empty"] ?: "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["changelog_dialog_update_button"] ?: "",
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "", icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
|
||||
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "",
|
||||
onDismiss = { showChangelogDialog = false }
|
||||
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)
|
||||
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
title = context.translation["manager.routes.home_logs"] ?: "Logs",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (isRefreshing) {
|
||||
|
||||
@@ -8,7 +8,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -46,11 +47,17 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
@@ -58,8 +65,10 @@ import java.net.URLEncoder
|
||||
@Composable
|
||||
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val listState = rememberLazyListState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -69,8 +78,15 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
LaunchedEffect(scrollState.value) {
|
||||
routes.navigation?.globalScrollOffset = scrollState.value
|
||||
val computedScrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
|
||||
else listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(computedScrollOffset) {
|
||||
routes.navigation?.globalScrollOffset = computedScrollOffset
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -106,162 +122,200 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = controlsHeight, bottom = routes.bottomPadding + 24.dp)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// THEME SWITCHER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
|
||||
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
|
||||
Switch(
|
||||
checked = currentThemeId == "APHELION",
|
||||
onCheckedChange = { isAphelion ->
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
|
||||
val newId = if (isAphelion) "APHELION" else "LEGACY"
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(Modifier.height(controlsHeight))
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
// UI SETTINGS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
|
||||
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// THEME SWITCHER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
|
||||
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
|
||||
var localThemeId by remember { mutableStateOf(currentThemeId) }
|
||||
|
||||
Switch(
|
||||
checked = localThemeId == "APHELION",
|
||||
onCheckedChange = { isAphelion ->
|
||||
val newId = if (isAphelion) "APHELION" else "LEGACY"
|
||||
localThemeId = newId // Update UI instantly
|
||||
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
// 1. Capture bitmap BEFORE theme change
|
||||
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||
|
||||
// 2. Request Reveal
|
||||
routes.navigation?.themeRevealState?.requestReveal(
|
||||
newThemeId = newId,
|
||||
originCenter = switchCenter,
|
||||
bitmap = bitmap
|
||||
)
|
||||
|
||||
// 3. Apply theme and persist
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(50)
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
|
||||
// Write to disk immediately on IO thread and finish
|
||||
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
writeJob.join() // Ensure it finishes its work before scope potentially closes
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 26.dp)
|
||||
.onGloballyPositioned { coords ->
|
||||
val rootPos = coords.positionInRoot()
|
||||
switchCenter = androidx.compose.ui.geometry.Offset(
|
||||
x = rootPos.x + coords.size.width / 2f,
|
||||
y = rootPos.y + coords.size.height / 2f
|
||||
)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
// UI SETTINGS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
|
||||
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RESET SETUP
|
||||
GlassCard {
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// MESSAGE LOGGER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
// UPDATES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FRIEND NOTES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
|
||||
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
|
||||
// RESET SETUP
|
||||
GlassCard {
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// MESSAGE LOGGER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
}
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
|
||||
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"])
|
||||
}
|
||||
|
||||
// FRIEND NOTES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
|
||||
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,10 +323,11 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = scrollState.value,
|
||||
scrollOffset = computedScrollOffset,
|
||||
enableMorph = true,
|
||||
titleAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
|
||||
@@ -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,12 +58,16 @@ 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
|
||||
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
val scrollState = rememberLazyListState()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
|
||||
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
|
||||
@@ -95,138 +99,348 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
"active" to activeTasks.size.toString(),
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
} else {
|
||||
translation.format(
|
||||
"summary_idle",
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
}
|
||||
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
|
||||
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
|
||||
|
||||
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
||||
title = context.translation["manager.routes.tasks"] ?: "Tasks",
|
||||
subtitle = subtitle,
|
||||
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
|
||||
actions = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
"active" to activeTasks.size.toString(),
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
} else {
|
||||
translation.format(
|
||||
"summary_idle",
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = containerTopPadding),
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = translation.format("running_count", "count" to activeTasks.size.toString()),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.clickable {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
mergeSelection(
|
||||
taskSelection.toList().also { taskSelection.clear() }
|
||||
.map { it.first to it.second!! }
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
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
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Merge,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
translation["merge_button"] ?: "Merge",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
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 }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 0.dp,
|
||||
bottom = routes.bottomPadding
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
|
||||
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(activeTasks, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(recentTasks, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
||||
title = context.translation["manager.routes.tasks"] ?: "Tasks",
|
||||
subtitle = subtitle,
|
||||
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
|
||||
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),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PlaylistAddCheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = activeTasks.size.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
title = titleText ?: "",
|
||||
message = messageText ?: "",
|
||||
title = titleText,
|
||||
message = messageText,
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
tasksTranslation = translation,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
@@ -291,11 +505,14 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
|
||||
|
||||
var documentFileMimeType by remember { mutableStateOf("") }
|
||||
var isDocumentFileReadable by remember { mutableStateOf(true) }
|
||||
|
||||
val docVal = task.extra?.toUri()
|
||||
val documentFile by rememberAsyncMutableState(
|
||||
defaultValue = null as DocumentFile?,
|
||||
keys = arrayOf(taskStatus.key)
|
||||
keys = arrayOf(taskStatus.name)
|
||||
) {
|
||||
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
|
||||
if (docVal == null) null
|
||||
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
|
||||
documentFileMimeType = type ?: ""
|
||||
isDocumentFileReadable = canRead()
|
||||
}
|
||||
@@ -409,48 +626,32 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
|
||||
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
|
||||
var loadFailed by remember { mutableStateOf(false) }
|
||||
documentFile?.let { doc ->
|
||||
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
|
||||
val imageRequest = ImageRequest.Builder(context.androidContext)
|
||||
.data(doc.uri)
|
||||
.cacheKey(doc.uri.toString())
|
||||
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
|
||||
.build()
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = imageRequest,
|
||||
imageLoader = context.imageLoader,
|
||||
onState = { state ->
|
||||
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
|
||||
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
|
||||
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
|
||||
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
|
||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
when (task.type) {
|
||||
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = null)
|
||||
TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = null)
|
||||
TaskType.SCHEDULED_SEND -> {
|
||||
val active = !taskStatus.isFinalStage()
|
||||
val rotation = if (active) {
|
||||
val transition = rememberInfiniteTransition(label = "scheduled_send")
|
||||
transition.animateFloat(initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "rotation").value
|
||||
} else 0f
|
||||
Box(modifier = Modifier.size(50.dp).clip(CircleShape).background(if (active) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f))) else SolidColor(Color.White.copy(alpha = 0.06f))), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(28.dp).rotate(rotation), tint = if (active) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary)
|
||||
val doc = documentFile
|
||||
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
|
||||
val imageRequest = ImageRequest.Builder(context.androidContext)
|
||||
.data(doc.uri)
|
||||
.cacheKey(doc.uri.toString())
|
||||
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
|
||||
.build()
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = imageRequest,
|
||||
imageLoader = context.imageLoader,
|
||||
onState = { state ->
|
||||
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
|
||||
}
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
|
||||
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
|
||||
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
|
||||
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
|
||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,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(),
|
||||
|
||||
@@ -45,9 +45,18 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.core.view.drawToBitmap
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
@@ -336,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) }
|
||||
@@ -406,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)
|
||||
@@ -441,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()
|
||||
@@ -561,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)
|
||||
@@ -578,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)
|
||||
@@ -588,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,
|
||||
@@ -610,6 +663,8 @@ object LegacyTheme : ThemeContract {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
val importLabel = context.translation["button.import"]
|
||||
@@ -657,10 +712,11 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
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),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
|
||||
@@ -696,19 +752,48 @@ object LegacyTheme : ThemeContract {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
|
||||
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
|
||||
Switch(
|
||||
checked = currentThemeId == "APHELION",
|
||||
var localThemeId by remember { mutableStateOf(currentThemeId) }
|
||||
|
||||
Switch( checked = localThemeId == "APHELION",
|
||||
onCheckedChange = { isAphelion ->
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
val newId = if (isAphelion) "APHELION" else "LEGACY"
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
context.config.writeConfig()
|
||||
localThemeId = newId // Update UI instantly
|
||||
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
// 1. Capture bitmap BEFORE theme change
|
||||
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||
|
||||
// 2. Request Reveal
|
||||
routes.navigation?.themeRevealState?.requestReveal(
|
||||
newThemeId = newId,
|
||||
originCenter = switchCenter,
|
||||
bitmap = bitmap
|
||||
)
|
||||
|
||||
// 3. Apply theme and persist
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(50)
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
|
||||
// Write to disk immediately on IO thread and finish
|
||||
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
writeJob.join() // Wait for write to finish
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
modifier = Modifier
|
||||
.padding(end = 26.dp)
|
||||
.onGloballyPositioned { coords ->
|
||||
val rootPos = coords.positionInRoot()
|
||||
switchCenter = androidx.compose.ui.geometry.Offset(
|
||||
x = rootPos.x + coords.size.width / 2f,
|
||||
y = rootPos.y + coords.size.height / 2f
|
||||
)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
@@ -836,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -869,10 +955,27 @@ object LegacyTheme : ThemeContract {
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = bottomPadding)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = pagePadding, vertical = 12.dp).fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
Text(text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp)
|
||||
Spacer(modifier = Modifier.width(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -893,20 +996,17 @@ object LegacyTheme : ThemeContract {
|
||||
text = translation["about_title"] ?: "About",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
color = Color.White,
|
||||
fontFamily = avenirNext,
|
||||
modifier = Modifier.clickable(interactionSource = tapSource, indication = null) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.value > 1500L) { tapCount.intValue = 0 }
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.value = now
|
||||
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
|
||||
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
|
||||
}
|
||||
if (tapCount.intValue >= 5) { tapCount.intValue = 0; routes.retroGame.navigate() }
|
||||
}
|
||||
)
|
||||
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
||||
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
@@ -929,7 +1029,7 @@ object LegacyTheme : ThemeContract {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(text = translation["about_story_title"] ?: "Our Story", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(text = aboutStory, fontSize = 14.sp, color = PurrfectPalette.textSecondary, lineHeight = 20.sp)
|
||||
Text(text = aboutStory, fontSize = 14.sp, color = Color(0xFFD9D3FF), lineHeight = 20.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,6 +1067,53 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeAbout.DeveloperCard(
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = imageRes),
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
PurrfectMarqueeText(
|
||||
text = name,
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val composeContext = LocalContext.current
|
||||
@@ -1095,7 +1242,6 @@ object LegacyTheme : ThemeContract {
|
||||
groupList = groups
|
||||
}
|
||||
updateScopeLists()
|
||||
requestLatestSnapshot()
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -1145,7 +1291,7 @@ object LegacyTheme : ThemeContract {
|
||||
val searchShape = RoundedCornerShape(18.dp)
|
||||
val searchBorder = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
@@ -1223,14 +1369,13 @@ object LegacyTheme : ThemeContract {
|
||||
val listState = rememberLazyListState()
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchActiveTasks(this)
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
fetchActiveTasks(scope)
|
||||
while (true) {
|
||||
fetchActiveTasks(this)
|
||||
fetchNewRecentTasks()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,6 +1441,37 @@ object LegacyTheme : ThemeContract {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
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 = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
mergeSelection(
|
||||
taskSelection.toList()
|
||||
.also { taskSelection.clear() }
|
||||
.map { it.first to it.second!! }
|
||||
)
|
||||
},
|
||||
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(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
@@ -1359,13 +1535,6 @@ object LegacyTheme : ThemeContract {
|
||||
TaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { listState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1386,7 +1555,6 @@ object LegacyTheme : ThemeContract {
|
||||
message = messageText ?: "",
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
tasksTranslation = translation,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
/**
|
||||
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
|
||||
* Provides more nuanced feedback than standard Compose haptics.
|
||||
*/
|
||||
object AphelionHaptics {
|
||||
|
||||
/**
|
||||
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
|
||||
*/
|
||||
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
val vibrator = vibratorManager?.defaultVibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
// Fallback for older APIs
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a "soft" impact feel, good for glass interactions.
|
||||
*/
|
||||
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||
} else {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
|
||||
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
private const val REVEAL_DURATION_MS = 3200
|
||||
private const val WAVE_BAND_WIDTH_PX = 300f
|
||||
|
||||
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
|
||||
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
|
||||
|
||||
@Composable
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
fun CircularRevealOverlay(
|
||||
context: RemoteSideContext,
|
||||
request: ThemeRevealRequest,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
// Safety check: if bitmap was recycled or is null, skip.
|
||||
val bitmap = request.oldThemeBitmap ?: run {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
if (bitmap.isRecycled) {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// Ensure the reveal is cleared even if navigation happens mid-animation
|
||||
DisposableEffect(request.id) {
|
||||
onDispose { onComplete() }
|
||||
}
|
||||
|
||||
val maxRadius = remember(configuration) {
|
||||
with(density) {
|
||||
val w = configuration.screenWidthDp.dp.toPx()
|
||||
val h = configuration.screenHeightDp.dp.toPx()
|
||||
sqrt(w * w + h * h)
|
||||
}
|
||||
}
|
||||
|
||||
val animatedRadius = remember(request.id) { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(request.id) {
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
animatedRadius.animateTo(
|
||||
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
|
||||
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
|
||||
val timeValue by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 10f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
|
||||
label = "wave_time_value"
|
||||
)
|
||||
|
||||
// --- AGSL SHADER LOGIC (Android 13+) ---
|
||||
|
||||
val runtimeShader = remember(bitmap) {
|
||||
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
|
||||
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
|
||||
}
|
||||
}
|
||||
|
||||
val shaderPaint = remember(runtimeShader) {
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = runtimeShader
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radius = animatedRadius.value
|
||||
val center = request.originCenter
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
runtimeShader.setFloatUniform("revealRadius", radius)
|
||||
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
|
||||
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
|
||||
runtimeShader.setFloatUniform("time", timeValue)
|
||||
runtimeShader.setFloatUniform("uProgress", progress)
|
||||
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
/**
|
||||
* Carries all data needed to execute one theme reveal transition.
|
||||
*/
|
||||
data class ThemeRevealRequest(
|
||||
val newThemeId: String,
|
||||
val originCenter: Offset,
|
||||
val oldThemeBitmap: android.graphics.Bitmap?,
|
||||
val id: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Observable state that lives on the [Navigation] instance.
|
||||
* Optimized for stability during rapid toggle events.
|
||||
*/
|
||||
class ThemeRevealState {
|
||||
|
||||
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Requests a new theme reveal animation.
|
||||
* Always starts a new reveal immediately, even if one is already in progress.
|
||||
*/
|
||||
fun requestReveal(
|
||||
newThemeId: String,
|
||||
originCenter: Offset,
|
||||
bitmap: android.graphics.Bitmap?
|
||||
) {
|
||||
// Clean up the old one first to prevent memory leaks and "dead periods"
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
|
||||
// Immediately update with the new request ID to force a fresh animation
|
||||
pendingReveal = ThemeRevealRequest(
|
||||
newThemeId = newThemeId,
|
||||
originCenter = originCenter,
|
||||
oldThemeBitmap = bitmap,
|
||||
id = System.currentTimeMillis() // Unique ID ensures fresh start
|
||||
)
|
||||
}
|
||||
|
||||
/** Called by the overlay composable once the animation has fully completed. */
|
||||
fun clearReveal() {
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
pendingReveal = null
|
||||
|
||||
// Manual memory management for the heavy screenshot bitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
/**
|
||||
* Finalized "Perfect Optic" AGSL Shader.
|
||||
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
|
||||
*/
|
||||
object WaveEdgeShader {
|
||||
|
||||
const val AGSL = """
|
||||
uniform shader content;
|
||||
uniform float revealRadius;
|
||||
uniform float2 revealCenter;
|
||||
uniform float bandWidth;
|
||||
uniform float time;
|
||||
uniform float uProgress;
|
||||
|
||||
float hash(float2 p) {
|
||||
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float valueNoise(float2 p) {
|
||||
float2 i = floor(p);
|
||||
float2 f = fract(p);
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
half4 main(float2 pos) {
|
||||
float d = distance(pos, revealCenter);
|
||||
|
||||
float energy = (1.0 - uProgress);
|
||||
// Slower, more majestic large noise
|
||||
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
|
||||
float totalNoise = noiseLarge * energy;
|
||||
|
||||
float dist = d - (revealRadius + totalNoise);
|
||||
|
||||
if (dist > 0.0) {
|
||||
return content.eval(pos);
|
||||
}
|
||||
|
||||
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
|
||||
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
|
||||
float effectStart = revealRadius - dynamicBand;
|
||||
|
||||
if (dist < -dynamicBand) {
|
||||
return half4(0.0);
|
||||
}
|
||||
|
||||
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
|
||||
|
||||
// Asymmetric crest: Sharp start, long slow tail
|
||||
float waveShape = pow(bandProgress, 2.0);
|
||||
|
||||
float2 dir = normalize(pos - revealCenter + 0.001);
|
||||
|
||||
// Refraction: Impactful but clear
|
||||
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
|
||||
float2 refractedPos = pos + dir * refractionAmt;
|
||||
|
||||
// Chromatic Aberration: Deep prism split
|
||||
float aberration = waveShape * (35.0 * energy + 10.0);
|
||||
half r = content.eval(refractedPos + dir * aberration).r;
|
||||
half g = content.eval(refractedPos).g;
|
||||
half b = content.eval(refractedPos - dir * aberration).b;
|
||||
|
||||
// Sharp highlight at the front edge
|
||||
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
|
||||
|
||||
// Leading edge softening (minor)
|
||||
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
|
||||
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
|
||||
|
||||
return half4(
|
||||
r + half(highlight),
|
||||
g + half(highlight),
|
||||
b + half(highlight),
|
||||
half(alpha)
|
||||
);
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,16 @@ class SaveFolderScreen : SetupScreen() {
|
||||
var currentFolder by remember {
|
||||
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
|
||||
}
|
||||
val readablePath = remember(currentFolder) {
|
||||
if (currentFolder.isBlank()) null
|
||||
else runCatching {
|
||||
val uri = android.net.Uri.parse(currentFolder)
|
||||
val path = uri.path ?: return@runCatching currentFolder
|
||||
if (path.contains("tree/")) {
|
||||
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
|
||||
} else currentFolder
|
||||
}.getOrElse { currentFolder }
|
||||
}
|
||||
var showNoPickerDialog by remember { mutableStateOf(false) }
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
@@ -104,7 +114,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
|
||||
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +45,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog as StandardDialog
|
||||
import androidx.core.net.toUri
|
||||
import com.github.skydoves.colorpicker.compose.*
|
||||
import com.google.gson.JsonParser
|
||||
@@ -57,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
|
||||
@@ -64,14 +72,156 @@ 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
|
||||
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()
|
||||
@@ -1093,7 +1289,7 @@ class AlertDialogs(
|
||||
val lat = remember { mutableStateOf(coordinates.first.toString()) }
|
||||
val lon = remember { mutableStateOf(coordinates.second.toString()) }
|
||||
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = {
|
||||
customCoordinatesDialog = false
|
||||
},
|
||||
@@ -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,9 +1554,8 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Edit message dialog
|
||||
if (showAddDialog) {
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = 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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,31 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Outline
|
||||
import android.os.Build
|
||||
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
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
@@ -19,20 +38,31 @@ import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.semantics.dialog
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.core.view.WindowCompat
|
||||
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
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext?.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
class DialogProperties constructor(
|
||||
val dismissOnBackPress: Boolean = true,
|
||||
val dismissOnClickOutside: Boolean = true,
|
||||
@@ -83,6 +113,17 @@ fun Dialog(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
var forceInline by remember(view) { mutableStateOf(false) }
|
||||
val hostActivity = remember(view) { view.context.findActivity() }
|
||||
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
|
||||
if (shouldUseInline) {
|
||||
InlineDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
content = content
|
||||
)
|
||||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val composition = rememberCompositionContext()
|
||||
@@ -110,11 +151,30 @@ fun Dialog(
|
||||
}
|
||||
|
||||
DisposableEffect(dialog) {
|
||||
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
|
||||
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
|
||||
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
val showDialog = {
|
||||
try {
|
||||
dialog.prepareWindowForHost()
|
||||
if (!dialog.isShowing) {
|
||||
dialog.show()
|
||||
}
|
||||
} catch (_: WindowManager.BadTokenException) {
|
||||
forceInline = true
|
||||
}
|
||||
}
|
||||
|
||||
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
|
||||
showDialog()
|
||||
} else {
|
||||
val listener = object : OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
v.removeOnAttachStateChangeListener(this)
|
||||
showDialog()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) = Unit
|
||||
}
|
||||
view.addOnAttachStateChangeListener(listener)
|
||||
}
|
||||
dialog.show()
|
||||
|
||||
onDispose {
|
||||
dialog.dismiss()
|
||||
@@ -131,6 +191,92 @@ fun Dialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissOnClickOutside: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val displayMetrics = LocalContext.current.resources.displayMetrics
|
||||
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) {
|
||||
visible = true
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = androidx.compose.ui.Alignment.Center,
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = dismissOnClickOutside
|
||||
),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
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
|
||||
) {
|
||||
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)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogWindowProvider {
|
||||
val window: Window
|
||||
}
|
||||
@@ -279,6 +425,26 @@ private class DialogWrapper(
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareWindowForHost() {
|
||||
val hostActivity = composeView.context.findActivity()
|
||||
val hostToken = composeView.applicationWindowToken
|
||||
?: composeView.windowToken
|
||||
?: composeView.rootView?.applicationWindowToken
|
||||
?: composeView.rootView?.windowToken
|
||||
if (hostActivity == null) {
|
||||
when {
|
||||
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
|
||||
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
}
|
||||
}
|
||||
if (hostToken != null) {
|
||||
window?.attributes = window?.attributes?.apply {
|
||||
token = hostToken
|
||||
}
|
||||
}
|
||||
hostActivity?.let { setOwnerActivity(it) }
|
||||
}
|
||||
|
||||
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
|
||||
dialogLayout.layoutDirection = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
||||
|
||||
@@ -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.4.0").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("280").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,206 @@
|
||||
## 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
|
||||
- New: Translucent Dialogs & refreshed animation
|
||||
- Fix: Adjusted Snap Preview Location
|
||||
|
||||
## v1.5.2
|
||||
- New: Continuous snap sender feature!
|
||||
- Fix: Snap send failure for E2E Chats
|
||||
|
||||
## v1.5.1
|
||||
- Fix: Splitting issue for video snaps sent through gallery media send override!
|
||||
- New: Toggle to turn off/on splitting for video snaps sent through send override
|
||||
- Fix: Aphelion task page layout optimization(tq to Kaladin)
|
||||
|
||||
## v1.5.0
|
||||
- Fix: Skip when marking as seen for newer versions of Snapchat
|
||||
- New: Hide Conversation Toolbox UI
|
||||
|
||||
## v1.4.9
|
||||
- Fix: Force AMOLED Theme for newer versions of Snapchat
|
||||
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)
|
||||
|
||||
## v1.4.8
|
||||
- Fix: Crash Issues for some devices
|
||||
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
|
||||
- Fix: Both side call recording for newer versions of snapchat
|
||||
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
|
||||
|
||||
## v1.4.6
|
||||
- Fix: Half Swipe Notifications for newer versions of snapchat
|
||||
- Fix: No Config import/export button if Aphelion theme is turned off
|
||||
|
||||
## v1.4.5
|
||||
- New: Spoof Snap Score Locally(tq to RSR)
|
||||
- New: Video Recording Timer(tq to RSR)
|
||||
- New: Auto Skip(tq to RSR)
|
||||
- Fix: Processing failed to save snaps(tq to RSR)
|
||||
- Fix: Story counter & story source for newer versions of snapchat(tq to RSR)
|
||||
- Fix: Features page using Aphelion scroll even when Aphelion is not in use(tq to Kaladin)
|
||||
- Fix: Aphelion Task page not showing the thumbnails of the completed tasks(tq to Kaladin)
|
||||
- Fix: Tasks page not showing the merge button when Aphelion is not in use(tq to Kaladin)
|
||||
- Fix: Features and its sub pages scroll state is remembered infintely, causing the pages to load where they were exited even after the app is closed(tq to Kaladin)
|
||||
- New: Expanded Friend Mutation Observer to include the new layout when Aphelion is in use(tq to Kaladin)
|
||||
- New: Theme Transition Animation(tq to Kaladin)
|
||||
- Fix: Friend feed menu button overlapping for group chats
|
||||
- Fix: Duplicate opera download & mark snaps as seen buttons
|
||||
|
||||
## v1.4.1
|
||||
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
|
||||
- Fix: Story Counter/Story Source position(tq to AhmedRaza)
|
||||
- Fix: Better Location Overlay Icon for newer Snapchat versions
|
||||
- Fix: Double tap to mark chat as read for newer Snapchat versions
|
||||
- New: Mark Chat as Read(Friend feed menu)
|
||||
- Fix: Opera Download button duplicating
|
||||
- Fix: All friends getting automatically added even after unselecting in the social tab
|
||||
- Fix: Spotlight Creator Info
|
||||
|
||||
## v1.4.0
|
||||
- Fix: Download profile picture button showing in all pages
|
||||
- Fix: Opera Download button & Mark Snaps as seen for newer snapchat versions
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1108,6 +1108,10 @@
|
||||
"name": "مؤشر مصدر القصة",
|
||||
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
|
||||
},
|
||||
"story_snap_jump": {
|
||||
"name": "تخطي تلقائي",
|
||||
"description": "يضيف زر تخطي للانتقال إلى أي سناب في القصة. اضغط على أيقونة التخطي أو العداد لفتح نافذة القفز"
|
||||
},
|
||||
"old_bitmoji_selfie": {
|
||||
"name": "سيلفي Bitmoji القديم",
|
||||
"description": "يعيد سيلفي Bitmoji من إصدارات Snapchat القديمة"
|
||||
@@ -1155,6 +1159,16 @@
|
||||
"settings_menu": {
|
||||
"name": "قائمة الإعدادات",
|
||||
"description": "اختر بين تخطيطات قائمة الإعدادات الجديدة والقديمة"
|
||||
},
|
||||
"spoof_snap_score": {
|
||||
"name": "تزييف نقاط سناب شات",
|
||||
"description": "يقوم بتزييف عدد نقاط سناب شات (المحلية فقط)",
|
||||
"properties": {
|
||||
"custom_snap_score": {
|
||||
"name": "النقاط المخصصة",
|
||||
"description": "تعيين نقاط السناب شات الوهمية (أقصى عدد هو 9,999,999)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1924,6 +1938,10 @@
|
||||
"hevc_recording": {
|
||||
"name": "تسجيل HEVC",
|
||||
"description": "يستخدم ترميز HEVC (H.265) لتسجيل الفيديو"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "مؤقت تسجيل الفيديو",
|
||||
"description": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -1190,6 +1211,14 @@
|
||||
"name": "Mark Snap as Seen Button",
|
||||
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"name": "Mark Snaps as Seen Mode",
|
||||
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
|
||||
},
|
||||
"mark_snap_as_seen_limit": {
|
||||
"name": "Mark Snaps as Seen Limit",
|
||||
"description": "How many snaps to process per run when the mode is set to limit"
|
||||
},
|
||||
"skip_when_marking_as_seen": {
|
||||
"name": "Skip When Marking as Seen",
|
||||
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
|
||||
@@ -2005,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",
|
||||
@@ -2340,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",
|
||||
@@ -2957,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",
|
||||
@@ -2969,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": {
|
||||
@@ -3178,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}",
|
||||
@@ -3190,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",
|
||||
@@ -3343,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",
|
||||
@@ -3394,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",
|
||||
@@ -3406,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3466,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.",
|
||||
@@ -3639,4 +3720,4 @@
|
||||
"openai": "OpenAI",
|
||||
"openrouter": "OpenRouter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"summary_active": "{active} active \u00b7 {recent} recent",
|
||||
"summary_idle": "Idle \u00b7 {recent} recent",
|
||||
"running_count": "{count} running",
|
||||
"tasks_tagline": "Monitor and manage background actions",
|
||||
"clear_button_description": "Clear tasks",
|
||||
"failed_to_open_file": "Failed to open file",
|
||||
"merge_files_toast": "Merging {count} files",
|
||||
@@ -361,7 +362,7 @@
|
||||
"search_button": "Search",
|
||||
"search_results_count": "{count} messages",
|
||||
"clear_history": "Clear search history",
|
||||
"subtitle": "Search and manage features"
|
||||
"subtitle": "Explore and manage premium features"
|
||||
},
|
||||
"bypass_status": {
|
||||
"active": "PurrAura Active",
|
||||
@@ -391,7 +392,7 @@
|
||||
"friends_empty_title": "No friends added yet",
|
||||
"groups_empty_title": "No groups synced yet",
|
||||
"streaks_expiration_short": "{hours}h",
|
||||
"social_tagline": "Manage scopes, streaks, and previews",
|
||||
"social_tagline": "Manage friends, groups, and streaks",
|
||||
"social_empty_hint": "Tap the + button to sync friends or groups.",
|
||||
"messaging_preview": {
|
||||
"bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background",
|
||||
@@ -569,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"
|
||||
@@ -1149,6 +1163,10 @@
|
||||
"name": "Story Source Indicator",
|
||||
"description": "Shows an icon indicating whether the snap was taken from the camera or uploaded from the gallery\nOnly works with friend stories"
|
||||
},
|
||||
"story_snap_jump": {
|
||||
"name": "Auto Skip",
|
||||
"description": "Adds a skip button to jump to any snap in a story. Tap the skip icon or counter to open the jump dialog"
|
||||
},
|
||||
"old_bitmoji_selfie": {
|
||||
"name": "Old Bitmoji Selfie",
|
||||
"description": "Brings back the Bitmoji selfies from older Snapchat versions"
|
||||
@@ -1196,6 +1214,16 @@
|
||||
"settings_menu": {
|
||||
"name": "Settings Menu",
|
||||
"description": "Choose between the new and legacy settings menu layouts"
|
||||
},
|
||||
"spoof_snap_score": {
|
||||
"name": "Spoof Snap Score",
|
||||
"description": "Spoof your Snap Score (local only)",
|
||||
"properties": {
|
||||
"custom_snap_score": {
|
||||
"name": "Custom Snap Score",
|
||||
"description": "The custom Snap Score you want to display (max 9,999,999)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1223,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"
|
||||
@@ -1239,6 +1275,14 @@
|
||||
"name": "Mark Snap as Seen Button",
|
||||
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"name": "Mark Snaps as Seen Mode",
|
||||
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
|
||||
},
|
||||
"mark_snap_as_seen_limit": {
|
||||
"name": "Mark Snaps as Seen Limit",
|
||||
"description": "How many snaps to process per run when the mode is set to limit"
|
||||
},
|
||||
"skip_when_marking_as_seen": {
|
||||
"name": "Skip When Marking as Seen",
|
||||
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
|
||||
@@ -1269,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"
|
||||
@@ -1588,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": {
|
||||
@@ -1614,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": {
|
||||
@@ -1965,6 +2055,18 @@
|
||||
"hevc_recording": {
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2054,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",
|
||||
@@ -2100,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": {
|
||||
@@ -2171,6 +2685,10 @@
|
||||
"force_message_encryption": {
|
||||
"name": "Force Message Encryption",
|
||||
"description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected"
|
||||
},
|
||||
"hide_conversation_toolbox_ui": {
|
||||
"name": "Hide Conversation Toolbox UI",
|
||||
"description": "Hides the PurrfectSnap conversation toolbox button that appears in Snapchat when End-To-End Encryption is enabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2280,6 +2798,7 @@
|
||||
"stealth": "\ud83d\udc7b Stealth Mode",
|
||||
"auto_reply": "\ud83d\udce8 Auto Reply",
|
||||
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
|
||||
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
|
||||
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
|
||||
"mark_stories_as_seen_locally": "\ud83d\udc40 Mark Stories as seen locally",
|
||||
"conversation_info": "\ud83d\udc64 Conversation Info",
|
||||
@@ -2296,11 +2815,23 @@
|
||||
"schedule_failed": "Scheduled snap failed",
|
||||
"schedule_cancelled_for": "Cancelled for {name}",
|
||||
"device_model": {
|
||||
"samsung_s25_ultra": "Samsung Galaxy S25 Ultra",
|
||||
"google_pixel_10_pro": "Google Pixel 10 Pro",
|
||||
"oneplus_13": "OnePlus 13",
|
||||
"xiaomi_15_ultra": "Xiaomi 15 Ultra",
|
||||
"null": "Device Default"
|
||||
"none": "Device Default",
|
||||
"random": "Random",
|
||||
"Pixel 8 Pro": "Pixel 8 Pro",
|
||||
"Pixel 9 Pro XL": "Pixel 9 Pro XL",
|
||||
"Pixel 10": "Pixel 10",
|
||||
"Pixel 10 Pro": "Pixel 10 Pro",
|
||||
"Pixel 10 Pro XL": "Pixel 10 Pro XL",
|
||||
"Pixel 10 Pro Fold": "Pixel 10 Pro Fold",
|
||||
"Galaxy S23 Ultra": "Galaxy S23 Ultra",
|
||||
"Galaxy S24 Ultra": "Galaxy S24 Ultra",
|
||||
"Galaxy S25 Ultra": "Galaxy S25 Ultra",
|
||||
"OnePlus 15": "OnePlus 15",
|
||||
"OnePlus Open": "OnePlus Open",
|
||||
"Xiaomi 15 Ultra": "Xiaomi 15 Ultra",
|
||||
"OPPO Find X9 Pro": "OPPO Find X9 Pro",
|
||||
"vivo X100 Pro": "vivo X100 Pro",
|
||||
"realme GT 6": "realme GT 6"
|
||||
},
|
||||
"settings_menu": {
|
||||
"default": "Default",
|
||||
@@ -2389,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",
|
||||
@@ -2442,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"
|
||||
},
|
||||
@@ -2478,6 +3019,10 @@
|
||||
"remove_audio_note_duration": "Remove Audio Note Duration",
|
||||
"remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability"
|
||||
},
|
||||
"mark_snap_as_seen_processing_mode": {
|
||||
"limit": "Limit Per Run",
|
||||
"complete": "Complete Queue"
|
||||
},
|
||||
"hide_ui_components": {
|
||||
"hide_profile_call_buttons": "Remove Profile Call Buttons",
|
||||
"hide_chat_call_buttons": "Remove Chat Call Buttons",
|
||||
@@ -2754,6 +3299,8 @@
|
||||
}
|
||||
},
|
||||
"friend_menu_option": {
|
||||
"mark_chat_as_read": "Mark Chat as Read",
|
||||
"mark_chat_as_read_toast": "Marked chat as read!",
|
||||
"mark_snaps_as_seen": "Mark Snaps as seen",
|
||||
"mark_stories_as_seen_locally": "Mark Stories as seen locally",
|
||||
"preview": "Preview",
|
||||
@@ -2814,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"
|
||||
@@ -2988,6 +3537,7 @@
|
||||
"positive": "Yes",
|
||||
"negative": "No",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"save": "Save",
|
||||
"open": "Open",
|
||||
"download": "Download",
|
||||
@@ -3005,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",
|
||||
@@ -3017,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": {
|
||||
@@ -3054,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",
|
||||
@@ -3149,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",
|
||||
@@ -3172,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",
|
||||
@@ -3245,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}",
|
||||
@@ -3257,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",
|
||||
@@ -3289,6 +3874,13 @@
|
||||
"title": "Send media as",
|
||||
"duration": "Duration: {duration}",
|
||||
"saveable_snap_hint": "Make Snap saveable in the chat",
|
||||
"single_send_hint": "Send as one snap",
|
||||
"continuous_send_toggle": "Continuous snap sender",
|
||||
"continuous_send_count_label": "Send count",
|
||||
"continuous_send_count_placeholder": "Enter number of sends",
|
||||
"continuous_send_hint": "This will send the same snap to the same recipient multiple times.",
|
||||
"continuous_send_invalid_count": "Enter a valid send count greater than 0",
|
||||
"continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.",
|
||||
"unlimited_duration": "Unlimited",
|
||||
"schedule": "Schedule",
|
||||
"select_time": "Select time",
|
||||
@@ -3392,10 +3984,10 @@
|
||||
"search": {
|
||||
"placeholder": "Search"
|
||||
},
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"filters": {
|
||||
"newest_first": "Newest first",
|
||||
"pick_a_date": "Pick a date",
|
||||
"title": "Filters",
|
||||
"search_by": "Search by",
|
||||
"since": "Since",
|
||||
"until": "Until",
|
||||
@@ -3413,6 +4005,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",
|
||||
@@ -3464,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",
|
||||
@@ -3476,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3536,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.",
|
||||
@@ -3574,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",
|
||||
@@ -3711,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."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ data class PropertyPair<T>(
|
||||
val name get() = key.name
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun Pair<PropertyKey<*>, PropertyValue<*>>.toPropertyPair(): PropertyPair<Any> =
|
||||
PropertyPair(first as PropertyKey<Any>, second as PropertyValue<Any>)
|
||||
|
||||
enum class FeatureNotice(
|
||||
val key: String
|
||||
) {
|
||||
|
||||
@@ -57,6 +57,16 @@ class Camera : ConfigContainer() {
|
||||
val startupDefaultCamera = unique("startup_default_camera", "front", "back") { requireRestart() }
|
||||
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+")) } }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ class Experimental : ConfigContainer() {
|
||||
class E2EEConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val encryptedMessageIndicator = boolean("encrypted_message_indicator")
|
||||
val forceMessageEncryption = boolean("force_message_encryption")
|
||||
val hideConversationToolboxUi = boolean("hide_conversation_toolbox_ui")
|
||||
}
|
||||
|
||||
class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) {
|
||||
@@ -62,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,15 +214,31 @@ 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() }
|
||||
val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() }
|
||||
val markSnapAsSeenProcessingMode = unique("mark_snap_as_seen_processing_mode", "limit", "complete").apply {
|
||||
set("limit")
|
||||
}
|
||||
val markSnapAsSeenLimit = integer("mark_snap_as_seen_limit", defaultValue = 50) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() }
|
||||
val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() }
|
||||
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",
|
||||
@@ -304,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,15 +199,30 @@ 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",
|
||||
"samsung_s25_ultra",
|
||||
"google_pixel_10_pro",
|
||||
"oneplus_13",
|
||||
"xiaomi_15_ultra"
|
||||
) {
|
||||
"none",
|
||||
"random",
|
||||
"Pixel 8 Pro",
|
||||
"Pixel 9 Pro XL",
|
||||
"Pixel 10",
|
||||
"Pixel 10 Pro",
|
||||
"Pixel 10 Pro XL",
|
||||
"Pixel 10 Pro Fold",
|
||||
"Galaxy S23 Ultra",
|
||||
"Galaxy S24 Ultra",
|
||||
"Galaxy S25 Ultra",
|
||||
"OnePlus 15",
|
||||
"OnePlus Open",
|
||||
"Xiaomi 15 Ultra",
|
||||
"OPPO Find X9 Pro",
|
||||
"vivo X100 Pro",
|
||||
"realme GT 6"
|
||||
) {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
customOptionTranslationPath = "features.options.device_model"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
|
||||
|
||||
val friendFeedMenuButtons = multiple(
|
||||
"friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray()
|
||||
"friend_feed_menu_buttons","conversation_info", "mark_chat_as_read", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray()
|
||||
).apply {
|
||||
set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key))
|
||||
}
|
||||
@@ -52,6 +52,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() }
|
||||
val storyCounter = boolean("story_counter") { requireRestart() }
|
||||
val storySourceIndicator = boolean("story_source_indicator") { requireRestart() }
|
||||
val storySnapJump = boolean("story_snap_jump") { requireRestart() }
|
||||
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
|
||||
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
|
||||
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
|
||||
@@ -62,4 +63,16 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
}
|
||||
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
|
||||
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
|
||||
|
||||
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
|
||||
val customSnapScore = string("custom_snap_score") {
|
||||
requireRestart()
|
||||
inputCheck = { input ->
|
||||
if (input.isEmpty()) true
|
||||
else input.replace(Regex("[^0-9]"), "").isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.eternal.purrfectsnap.common.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Shared palette for PurrfectSnap overlay UI (dialogs, story overlays) shown inside Snapchat.
|
||||
* Matches the PurrfectSnap manager app's premium look. Used by core module.
|
||||
*/
|
||||
object PurrfectOverlayPalette {
|
||||
val glowPrimary = Color(0xFF8C7BFF)
|
||||
val glowSecondary = Color(0xFF5FD8FF)
|
||||
val textPrimary = Color.White
|
||||
val textSecondary = Color(0xFFD9D3FF)
|
||||
val cardOverlayColor = Color(0xFF2A2452).copy(alpha = 0.95f)
|
||||
val cardOverlay = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package me.eternal.purrfectsnap.common.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun AphelionFriendMutationToast(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
bitmojiUrl: String?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
var bitmojiBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(bitmojiUrl) {
|
||||
if (bitmojiUrl != null) {
|
||||
runCatching {
|
||||
me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, _ ->
|
||||
bitmojiBitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
delay(5000)
|
||||
visible = false
|
||||
delay(500)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow),
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 16.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationY = -100f * (1f - progress)
|
||||
alpha = progress
|
||||
scaleX = 0.9f + (0.1f * progress)
|
||||
scaleY = 0.9f + (0.1f * progress)
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 340.dp)
|
||||
.shadow(20.dp, RoundedCornerShape(28.dp)),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = Color(0xE61B152E),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.15f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (bitmojiBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = bitmojiBitmap!!.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ dependencies {
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.rhino)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
|
||||
|
||||
implementation(project(":common"))
|
||||
implementation(project(":mapper"))
|
||||
@@ -47,6 +49,8 @@ dependencies {
|
||||
implementation(libs.androidx.material.ripple)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.hiddenapibypass)
|
||||
implementation(libs.colorpicker.compose)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Cancel
|
||||
import androidx.compose.runtime.Composable
|
||||
import java.lang.reflect.Method
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -32,12 +34,14 @@ import me.eternal.purrfectsnap.core.data.SnapClassCache
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
|
||||
import me.eternal.purrfectsnap.core.ui.InAppOverlay
|
||||
import me.eternal.purrfectsnap.core.ui.CustomComposable
|
||||
import me.eternal.purrfectsnap.core.util.LSPatchUpdater
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookAdapter
|
||||
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.mapper.impl.PlatformClientAttestationMapper
|
||||
import me.eternal.purrfectsnap.common.ui.components.AphelionFriendMutationToast
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -216,6 +220,26 @@ class PurrfectSnap {
|
||||
log.verbose("Initializing features...")
|
||||
runCatching {
|
||||
features.init()
|
||||
|
||||
// Wire up the premium friend mutation toast provider
|
||||
features.get(me.eternal.purrfectsnap.core.features.impl.FriendMutationObserver::class)?.let { observer ->
|
||||
observer.aphelionToastProvider = { icon, text, bitmojiUrl, onDismiss ->
|
||||
lateinit var composable: CustomComposable
|
||||
composable = @Composable {
|
||||
AphelionFriendMutationToast(
|
||||
icon = icon,
|
||||
text = text,
|
||||
bitmojiUrl = bitmojiUrl,
|
||||
onDismiss = {
|
||||
inAppOverlay.removeCustomComposable(composable)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
inAppOverlay.addCustomComposable(composable)
|
||||
}
|
||||
}
|
||||
|
||||
log.verbose("Features initialized successfully")
|
||||
}.onFailure { throwable ->
|
||||
log.error("Failed to initialize features", throwable)
|
||||
|
||||
@@ -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(),
|
||||
@@ -125,6 +129,7 @@ class FeatureManager(
|
||||
PreventForcedLogout(),
|
||||
ConversationToolbox(),
|
||||
SpotlightCommentsUsername(),
|
||||
SpotlightCreatorInfo(),
|
||||
OperaStoryCounter(),
|
||||
OperaViewerParamsOverride(),
|
||||
StealthModeIndicator(),
|
||||
@@ -153,12 +158,14 @@ class FeatureManager(
|
||||
AutoDeleteSentMessages(),
|
||||
FriendNotes(),
|
||||
DoubleTapChatAction(),
|
||||
VideoRecordTimer(),
|
||||
SnapScoreChanges(),
|
||||
DisableSnapModeRestrictions(),
|
||||
MessageTranslator(),
|
||||
PreventForcedKeyboard(),
|
||||
CustomTheming(),
|
||||
HideTypingIndicator(),
|
||||
FakeSnapScore(),
|
||||
)
|
||||
|
||||
features.values.toList().forEach { feature ->
|
||||
|
||||
@@ -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,13 +4,13 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.google.gson.JsonObject
|
||||
import me.eternal.purrfectsnap.common.data.FriendLinkType
|
||||
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import java.io.InputStreamReader
|
||||
import java.util.Calendar
|
||||
@@ -23,25 +23,28 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
private val channelId by lazy {
|
||||
"friend_mutation_observer".also {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
it,
|
||||
translation["notification_channel_name"],
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
NotificationChannel(it, translation["notification_channel_name"], NotificationManager.IMPORTANCE_HIGH)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFriendAddSource(userId: String): String? {
|
||||
return addSourceCache[userId]
|
||||
}
|
||||
// Injected from app layer — keeps core free of ui.* imports
|
||||
var aphelionToastProvider: ((
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
bitmojiUrl: String?,
|
||||
onDismiss: () -> Unit
|
||||
) -> Unit)? = null
|
||||
|
||||
fun getFriendAddSource(userId: String): String? = addSourceCache[userId]
|
||||
|
||||
private fun sendMutationNotification(icon: ImageVector, contentText: String, friendInfo: FriendInfo? = null) {
|
||||
val currentTheme = context.config.global.uiSettings.managerTheme.get()
|
||||
val isAphelion = currentTheme == "APHELION"
|
||||
|
||||
private fun sendWarnNotification(
|
||||
contentText: String
|
||||
) {
|
||||
notificationManager.notify(System.nanoTime().toInt(),
|
||||
Notification.Builder(context.androidContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(translation["notification_channel_name"])
|
||||
.setContentText(contentText)
|
||||
.setShowWhen(true)
|
||||
@@ -49,11 +52,21 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
.build()
|
||||
)
|
||||
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
contentText,
|
||||
durationMs = 7000
|
||||
)
|
||||
val provider = aphelionToastProvider
|
||||
if (isAphelion && provider != null) {
|
||||
val bitmojiUrl = friendInfo?.let {
|
||||
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.getBitmojiSelfie(
|
||||
it.bitmojiSelfieId,
|
||||
it.bitmojiAvatarId,
|
||||
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
|
||||
)
|
||||
}
|
||||
provider(icon, contentText, bitmojiUrl) {
|
||||
// onDismiss handled inside the provider lambda in app layer
|
||||
}
|
||||
} else {
|
||||
context.inAppOverlay.showStatusToast(icon, contentText, durationMs = 7000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUsername(friendInfo: FriendInfo): String {
|
||||
@@ -65,44 +78,34 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
private fun prettyPrintBirthday(month: Int, day: Int): String {
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar[Calendar.MONTH] = month
|
||||
return calendar.getDisplayName(
|
||||
Calendar.MONTH,
|
||||
Calendar.LONG,
|
||||
context.translation.loadedLocale
|
||||
)?.toString() + " " + day
|
||||
return calendar.getDisplayName(Calendar.MONTH, Calendar.LONG, context.translation.loadedLocale)?.toString() + " " + day
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
val config by context.config.messaging.friendMutationNotifier
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!event.url.contains("ami/friends")) return@subscribe
|
||||
event.onSuccess { buffer ->
|
||||
runCatching {
|
||||
val jsonObject = context.gson.fromJson(InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), JsonObject::class.java)
|
||||
|
||||
jsonObject.getAsJsonArray("added_friends").map { it.asJsonObject }.forEach { friend ->
|
||||
jsonObject.getAsJsonArray("added_friends")?.map { it.asJsonObject }?.forEach { friend ->
|
||||
val userId = friend.get("user_id").asString
|
||||
(friend.get("add_source")?.asString?.takeIf {
|
||||
it.isNotBlank()
|
||||
} ?: friend.get("add_source_type")?.asString?.takeIf {
|
||||
it.isNotBlank()
|
||||
})?.let {
|
||||
(friend.get("add_source")?.asString?.takeIf { it.isNotBlank() }
|
||||
?: friend.get("add_source_type")?.asString?.takeIf { it.isNotBlank() })?.let {
|
||||
addSourceCache[userId] = it
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isEmpty()) return@runCatching
|
||||
|
||||
jsonObject.getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend ->
|
||||
jsonObject.getAsJsonArray("friends")?.map { it.asJsonObject }?.forEach { friend ->
|
||||
runCatching {
|
||||
val userId = friend.get("user_id")?.asString
|
||||
val userId = friend.get("user_id")?.asString ?: return@forEach
|
||||
if (userId == context.database.myUserId) return@forEach
|
||||
val databaseFriend = context.database.getFriendInfo(userId ?: return@forEach) ?: return@forEach
|
||||
val databaseFriend = context.database.getFriendInfo(userId) ?: return@forEach
|
||||
if (FriendLinkType.fromValue(databaseFriend.friendLinkType) != FriendLinkType.MUTUAL) return@forEach
|
||||
|
||||
if (config.contains("remove_friend") && friend.get("direction")?.asString == "OUTGOING" && !friend.has("fidelius_info")) {
|
||||
sendWarnNotification(translation.format("friend_removed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.PersonRemove, translation.format("friend_removed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
@@ -111,47 +114,38 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
((it shr 32).toInt()).toString().padStart(2, '0') + "-" + (it.toInt()).toString().padStart(2, '0')
|
||||
} != friend.get("birthday")?.asString
|
||||
) {
|
||||
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let {
|
||||
prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt())
|
||||
}
|
||||
|
||||
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let { prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt()) }
|
||||
if (!friend.has("birthday")) {
|
||||
sendWarnNotification(translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()), databaseFriend)
|
||||
} else {
|
||||
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let {
|
||||
prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt())
|
||||
}
|
||||
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let { prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt()) }
|
||||
if (oldBirthday == null) {
|
||||
sendWarnNotification(translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()), databaseFriend)
|
||||
} else {
|
||||
sendWarnNotification(translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()), databaseFriend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_avatar_changes") && databaseFriend.bitmojiAvatarId != friend.get("bitmoji_avatar_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_selfie_changes") && databaseFriend.bitmojiSelfieId != friend.get("bitmoji_selfie_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_background_changes") && databaseFriend.bitmojiBackgroundId != friend.get("bitmoji_background_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Image, translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_scene_changes") && databaseFriend.bitmojiSceneId != friend.get("bitmoji_scene_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Landscape, translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to process friend", it)
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to process friend", it) }
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to process friends", it)
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to process friends", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.core.ui.InAppOverlay
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
@@ -26,12 +31,21 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private var wasInCall = false
|
||||
private var callDownloadSession: CallDownloadSession? = null
|
||||
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
|
||||
private val activeRemoteStreams = ConcurrentHashMap.newKeySet<Int>()
|
||||
private var fallbackMicRecord: AudioRecord? = null
|
||||
private var fallbackMicJob: Job? = null
|
||||
private var fallbackMicStartupJob: Job? = null
|
||||
private var pendingCallEndJob: Job? = null
|
||||
private var lastRemoteActivityTimestamp = 0L
|
||||
private var selfSideStreamOpened = false
|
||||
|
||||
private val uiState get() = context.inAppOverlay.callRecorderState
|
||||
private val callRecorderConfig get() = context.config.downloader.callRecorder
|
||||
|
||||
inner class CallStreamWrapper(
|
||||
private val audioFormat: AudioFormat,
|
||||
private val sourceLabel: String = "unknown",
|
||||
private val onStreamOpened: (() -> Unit)? = null,
|
||||
private val startTimestamp: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
private var stream: OutputStream? = null
|
||||
@@ -49,6 +63,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
audioFormat.encoding
|
||||
) ?: return
|
||||
)
|
||||
context.log.verbose(
|
||||
"Opened call stream source=$sourceLabel sampleRate=${audioFormat.sampleRate} channels=${audioFormat.channelCount} encoding=${audioFormat.encoding}",
|
||||
"CallRecorder"
|
||||
)
|
||||
onStreamOpened?.invoke()
|
||||
}
|
||||
}
|
||||
runCatching { stream?.write(buffer) }
|
||||
@@ -63,6 +82,9 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun finalizeSession() {
|
||||
val session = callDownloadSession ?: return
|
||||
context.log.verbose("Finalizing call recording session")
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("finalizeSession")
|
||||
runCatching { session.end() }
|
||||
callDownloadSession = null
|
||||
streams.values.forEach { it.close() }
|
||||
@@ -80,12 +102,14 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
|
||||
ensureSessionStarted()
|
||||
scheduleFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
if (uiState.isRecording) {
|
||||
uiState.isRecording = false
|
||||
stopFallbackMicCapture("stopRecording")
|
||||
finalizeSession()
|
||||
}
|
||||
}
|
||||
@@ -93,6 +117,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallStarted(conversationId: String) {
|
||||
if (wasInCall) return
|
||||
wasInCall = true
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
selfSideStreamOpened = false
|
||||
|
||||
val author = (if (context.database.getConversationType(conversationId) == 1) {
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
|
||||
@@ -120,6 +149,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallEnded() {
|
||||
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
|
||||
wasInCall = false
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("onCallEnded")
|
||||
finalizeSession()
|
||||
streams.clear()
|
||||
|
||||
@@ -178,6 +212,36 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamActive(streamId: Int, reason: String) {
|
||||
lastRemoteActivityTimestamp = System.currentTimeMillis()
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
if (activeRemoteStreams.add(streamId)) {
|
||||
context.log.verbose("Remote stream active id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamInactive(streamId: Int, reason: String) {
|
||||
if (activeRemoteStreams.remove(streamId)) {
|
||||
context.log.verbose("Remote stream inactive id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
scheduleCallEndCheck(reason)
|
||||
}
|
||||
|
||||
private fun scheduleCallEndCheck(reason: String, delayMs: Long = 1500L) {
|
||||
if (!wasInCall || lastRemoteActivityTimestamp == 0L || activeRemoteStreams.isNotEmpty()) return
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = context.coroutineScope.launch {
|
||||
delay(delayMs)
|
||||
if (!wasInCall) return@launch
|
||||
if (activeRemoteStreams.isNotEmpty()) return@launch
|
||||
val idleFor = System.currentTimeMillis() - lastRemoteActivityTimestamp
|
||||
if (idleFor < delayMs) return@launch
|
||||
context.log.verbose("Call end detected via remote inactivity reason=$reason idleFor=${idleFor}ms", "CallRecorder")
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSessionStarted() {
|
||||
if (callDownloadSession != null) return
|
||||
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
@@ -186,6 +250,209 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
onCallStarted(conversationId)
|
||||
}
|
||||
|
||||
private fun isCallContextActive(): Boolean {
|
||||
return wasInCall || uiState.showOverlay || uiState.isRecording
|
||||
}
|
||||
|
||||
private fun isDirectVoiceCaptureSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_CALL ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_UPLINK
|
||||
}
|
||||
|
||||
private fun isLikelyCallMicSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.DEFAULT ||
|
||||
audioSource == MediaRecorder.AudioSource.MIC ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_RECOGNITION ||
|
||||
audioSource == MediaRecorder.AudioSource.UNPROCESSED ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE
|
||||
}
|
||||
|
||||
private fun registerAudioRecordStream(audioRecord: AudioRecord, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioRecord.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val audioSource = runCatching { audioRecord.audioSource }.getOrNull()
|
||||
val shouldCapture = isDirectVoiceCaptureSource(audioSource) ||
|
||||
(isCallContextActive() && isLikelyCallMicSource(audioSource))
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioRecord.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "self-internal:$reason",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
if (audioRecord !== fallbackMicRecord) {
|
||||
stopFallbackMicCapture("internalSelfStreamOpened")
|
||||
}
|
||||
}
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
context.log.verbose(
|
||||
"Registered AudioRecord stream source=$audioSource reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isDirectVoiceCaptureSource(audioSource) || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCaptureSelfSide(): Boolean {
|
||||
return callRecorderConfig.callRecorder.get() != "only_record_others"
|
||||
}
|
||||
|
||||
private fun scheduleFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null) return
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = context.coroutineScope.launch {
|
||||
delay(1200)
|
||||
if (!isActive || !uiState.isRecording || selfSideStreamOpened || fallbackMicJob != null) return@launch
|
||||
startFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null || !uiState.isRecording) return
|
||||
|
||||
val sampleRate = 48_000
|
||||
val channelMask = AudioFormat.CHANNEL_IN_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
|
||||
if (minBufferSize <= 0) {
|
||||
context.log.warn("Fallback mic capture unavailable: invalid min buffer size $minBufferSize", "CallRecorder")
|
||||
return
|
||||
}
|
||||
|
||||
val audioFormat = AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask)
|
||||
.setEncoding(encoding)
|
||||
.build()
|
||||
|
||||
val audioRecord = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(audioFormat)
|
||||
.setBufferSizeInBytes(minBufferSize * 2)
|
||||
.build()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to create fallback mic recorder", it)
|
||||
return
|
||||
}
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
context.log.warn("Fallback mic recorder failed to initialize", "CallRecorder")
|
||||
runCatching { audioRecord.release() }
|
||||
return
|
||||
}
|
||||
|
||||
fallbackMicRecord = audioRecord
|
||||
context.log.verbose("Starting fallback mic capture", "CallRecorder")
|
||||
|
||||
fallbackMicJob = context.coroutineScope.launch(Dispatchers.IO) {
|
||||
val buffer = ByteArray(minBufferSize.coerceAtLeast(2048))
|
||||
val fallbackWrapper = CallStreamWrapper(
|
||||
audioFormat = audioFormat,
|
||||
sourceLabel = "self-fallback",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
}
|
||||
)
|
||||
val echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
val noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
try {
|
||||
audioRecord.startRecording()
|
||||
while (isActive && uiState.isRecording && isCallContextActive() && fallbackMicRecord === audioRecord) {
|
||||
val bytesRead = runCatching {
|
||||
audioRecord.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||
}.getOrElse {
|
||||
context.log.error("Fallback mic read failed", it)
|
||||
break
|
||||
}
|
||||
|
||||
if (bytesRead > 0) {
|
||||
fallbackWrapper.write(buffer.copyOf(bytesRead))
|
||||
} else {
|
||||
delay(10)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
context.log.error("Fallback mic capture crashed", e)
|
||||
} finally {
|
||||
fallbackWrapper.close()
|
||||
runCatching { audioRecord.stop() }
|
||||
echoCanceler?.release()
|
||||
noiseSuppressor?.release()
|
||||
runCatching { audioRecord.release() }
|
||||
if (fallbackMicRecord === audioRecord) {
|
||||
fallbackMicRecord = null
|
||||
fallbackMicJob = null
|
||||
}
|
||||
context.log.verbose("Stopped fallback mic capture", "CallRecorder")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFallbackMicCapture(reason: String) {
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = null
|
||||
if (fallbackMicJob != null || fallbackMicRecord != null) {
|
||||
context.log.verbose("Stopping fallback mic capture reason=$reason", "CallRecorder")
|
||||
}
|
||||
fallbackMicJob?.cancel()
|
||||
fallbackMicJob = null
|
||||
fallbackMicRecord?.let { record ->
|
||||
runCatching { record.stop() }
|
||||
runCatching { record.release() }
|
||||
}
|
||||
fallbackMicRecord = null
|
||||
}
|
||||
|
||||
private fun isVoiceCommunicationTrack(attributes: AudioAttributes?, streamType: Int?): Boolean {
|
||||
return attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
}
|
||||
|
||||
private fun registerAudioTrackStream(audioTrack: AudioTrack, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioTrack.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val attributes = runCatching { audioTrack.audioAttributes }.getOrNull()
|
||||
val streamType = runCatching { audioTrack.streamType }.getOrNull()
|
||||
val isVoiceCommunication = isVoiceCommunicationTrack(attributes, streamType)
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(isCallContextActive() && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioTrack.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "remote:$reason"
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
markRemoteStreamActive(streamId, "register:$reason")
|
||||
context.log.verbose(
|
||||
"Registered AudioTrack stream streamType=$streamType usage=${attributes?.usage} reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isVoiceCommunication || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
|
||||
if (requestedLength <= 0 || maxLength <= 0) return null
|
||||
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
|
||||
@@ -209,6 +476,13 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFloatArrayToByteArray(data: FloatArray, offset: Int, sampleCount: Int): ByteArray? {
|
||||
val (safeOffset, safeLength) = clampCopyRange(offset, sampleCount, data.size) ?: return null
|
||||
return ByteArray(safeLength * Float.SIZE_BYTES).also {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (callRecorderConfig.callRecorder.getNullable() == null) return
|
||||
|
||||
@@ -224,30 +498,16 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioRecord::class.java.apply {
|
||||
if (recorderConfig == "only_record_others") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val audioSource = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioRecordStream(param.thisObject<AudioRecord>(), "constructor")
|
||||
}
|
||||
|
||||
hook("read", HookStage.AFTER) { param ->
|
||||
val result = param.getResult() as? Int ?: 0
|
||||
if (result <= 0) return@hook
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val audioRecord = param.thisObject<AudioRecord>()
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()]
|
||||
?: registerAudioRecordStream(audioRecord, "read")
|
||||
?: return@hook
|
||||
|
||||
val buffer = when (val data = param.arg<Any>(0)) {
|
||||
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
|
||||
@@ -263,11 +523,19 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
copyFloatArrayToByteArray(data, offset, result) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("startRecording", HookStage.AFTER) {
|
||||
registerAudioRecordStream(it.thisObject<AudioRecord>(), "startRecording")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("release", HookStage.BEFORE) {
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
@@ -278,29 +546,15 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioTrack::class.java.apply {
|
||||
if (recorderConfig == "only_record_self") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val streamType = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioTrackStream(param.thisObject<AudioTrack>(), "constructor")
|
||||
}
|
||||
|
||||
hook("write", HookStage.BEFORE) { param ->
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val streamId = param.thisObject<Any>().hashCode()
|
||||
markRemoteStreamActive(streamId, "write")
|
||||
val wrapper = streams[streamId]
|
||||
?: registerAudioTrackStream(param.thisObject<AudioTrack>(), "write")
|
||||
?: return@hook
|
||||
val data = param.arg<Any>(0)
|
||||
|
||||
val buffer = when (data) {
|
||||
@@ -328,13 +582,34 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
val requestedSize = param.argNullable<Int>(2) ?: data.size
|
||||
copyFloatArrayToByteArray(data, offset, requestedSize) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("play", HookStage.AFTER) {
|
||||
val audioTrack = it.thisObject<AudioTrack>()
|
||||
markRemoteStreamActive(audioTrack.hashCode(), "play")
|
||||
registerAudioTrackStream(audioTrack, "play")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "stop")
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
hook("pause", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "pause")
|
||||
}
|
||||
hook("flush", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "flush")
|
||||
}
|
||||
hook("release", HookStage.BEFORE) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "release")
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -67,6 +75,8 @@ import me.eternal.purrfectsnap.core.ui.debugEditText
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.core.util.SNAPCHAT_13_80_VERSION
|
||||
import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast
|
||||
import me.eternal.purrfectsnap.core.util.media.PreviewUtils
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.media.MediaInfo
|
||||
@@ -82,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
|
||||
@@ -105,13 +119,15 @@ 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")
|
||||
}
|
||||
private val useModernOperaViewerContext by lazy {
|
||||
isSnapchatVersionAtLeast(
|
||||
context.mappings.getSnapchatPackageInfo()?.versionName,
|
||||
SNAPCHAT_13_80_VERSION
|
||||
)
|
||||
}
|
||||
|
||||
fun provideDownloadManagerClient(
|
||||
mediaIdentifier: String,
|
||||
@@ -240,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()
|
||||
@@ -272,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),
|
||||
@@ -325,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),
|
||||
@@ -347,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
|
||||
|
||||
@@ -463,6 +405,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
return messageContext
|
||||
}
|
||||
|
||||
private fun resolveLegacyViewerMessageContext(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
|
||||
val parts = paramMap?.get("MESSAGE_ID")
|
||||
?.toString()
|
||||
?.split(':')
|
||||
?.takeIf { it.size == 3 }
|
||||
?: return null
|
||||
|
||||
return OperaViewerMessageContext(
|
||||
conversationId = parts[0],
|
||||
clientMessageId = parts[2].toLongOrNull() ?: return null
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? {
|
||||
val parts = rawValue.split(':')
|
||||
if (parts.size < 3) return null
|
||||
@@ -480,6 +435,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
|
||||
fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
|
||||
if (paramMap == null) return null
|
||||
if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext(paramMap)
|
||||
|
||||
paramMap["MESSAGE_ID"]?.toString()
|
||||
?.let(::parseViewerMessageContext)
|
||||
@@ -496,6 +452,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
|
||||
fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? {
|
||||
if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext()
|
||||
|
||||
val messaging = context.feature(Messaging::class)
|
||||
val currentConversationId = messaging.openedConversationUUID?.toString()
|
||||
val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L }
|
||||
@@ -855,19 +813,25 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
}
|
||||
.toList()
|
||||
val firstLayerParamMap = layerParamMaps.firstOrNull()
|
||||
val mediaParamMap: ParamMap = (
|
||||
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
|
||||
val mediaParamMap: ParamMap = if (useModernOperaViewerContext) {
|
||||
(
|
||||
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
|
||||
layerParamMaps.firstOrNull {
|
||||
it.containsKey("MESSAGE_ID") &&
|
||||
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
|
||||
}
|
||||
?: firstLayerParamMap?.takeIf {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
?: layerParamMaps.firstOrNull {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
layerParamMaps.firstOrNull {
|
||||
it.containsKey("MESSAGE_ID") &&
|
||||
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
?: firstLayerParamMap?.takeIf {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
?: layerParamMaps.firstOrNull {
|
||||
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
|
||||
}
|
||||
) ?: return@onOperaViewStateCallback
|
||||
} ?: return@onOperaViewStateCallback
|
||||
|
||||
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
|
||||
val isVideo = mediaParamMap.containsKey("video_media_info_list")
|
||||
@@ -888,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
@@ -211,6 +211,10 @@ class BetterLocation : Feature("Better Location") {
|
||||
}
|
||||
|
||||
val mapViewId = context.resources.getId("mapview")
|
||||
val statusBarHeight = context.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
.takeIf { it > 0 }
|
||||
?.let { context.resources.getDimensionPixelSize(it) }
|
||||
?: 0
|
||||
|
||||
if (context.config.global.betterLocation.showBatteryLevel.get()) {
|
||||
findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param ->
|
||||
@@ -259,8 +263,8 @@ class BetterLocation : Feature("Better Location") {
|
||||
}.apply {
|
||||
layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
addRule(RelativeLayout.ALIGN_PARENT_LEFT)
|
||||
// Keep the button below the top map chips (Memories/Visited/Popular/Favorites).
|
||||
setMargins(0, (88 * context.resources.displayMetrics.density).toInt(), 0, 0)
|
||||
// Keep the button below the map chips and clear the status bar area on taller layouts.
|
||||
setMargins(0, statusBarHeight + this@BetterLocation.context.userInterface.dpToPx(84), 0, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
data class DeviceInfo(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val brand: String,
|
||||
val device: String,
|
||||
val product: String,
|
||||
val hardware: String,
|
||||
val board: String,
|
||||
val bootloader: String,
|
||||
val display: String,
|
||||
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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]
|
||||
}
|
||||
|
||||
fun generateFingerprint(deviceInfo: DeviceInfo, buildVersion: String): String {
|
||||
val id = "AP3A.${System.currentTimeMillis().toString().take(6)}.005"
|
||||
val incremental = System.nanoTime().toString().take(8)
|
||||
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)
|
||||
random.nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun androidIdToBytes(androidId: String): ByteArray {
|
||||
return try {
|
||||
val len = androidId.length
|
||||
val data = ByteArray(len / 2)
|
||||
var i = 0
|
||||
while (i < len) {
|
||||
data[i / 2] = ((Character.digit(androidId[i], 16) shl 4) + Character.digit(androidId[i + 1], 16)).toByte()
|
||||
i += 2
|
||||
}
|
||||
data
|
||||
} catch (e: Exception) {
|
||||
ByteArray(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,20 @@ import android.graphics.drawable.shapes.Shape
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
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.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
@@ -29,9 +36,10 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.*
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.features.impl.ui.ConversationToolbox
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.ui.findParent
|
||||
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
@@ -185,6 +193,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveKeyActionContainer(startView: View): ViewGroup? {
|
||||
val ancestors = generateSequence(startView) { current ->
|
||||
current.parent as? View
|
||||
}.filterIsInstance<ViewGroup>().toList()
|
||||
|
||||
return ancestors.firstOrNull { candidate ->
|
||||
candidate is LinearLayout && candidate.orientation == LinearLayout.VERTICAL
|
||||
} ?: ancestors.firstOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n", "DiscouragedApi")
|
||||
override fun init() {
|
||||
if (!isEnabled) return
|
||||
@@ -231,30 +249,34 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
}
|
||||
|
||||
onNextActivityCreate(defer = true) {
|
||||
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
|
||||
context.database.getDMOtherParticipant(it) != null
|
||||
}) { dialog, conversationId ->
|
||||
val friendId = remember {
|
||||
context.database.getDMOtherParticipant(conversationId)
|
||||
} ?: return@addComposable
|
||||
val fingerprint = remember {
|
||||
runCatching {
|
||||
e2eeInterface.getSecretFingerprint(friendId)
|
||||
}.getOrNull()
|
||||
}
|
||||
if (fingerprint != null) {
|
||||
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
|
||||
} else {
|
||||
Text(translation["toolbox.no_shared_key"])
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Button(onClick = {
|
||||
dialog.dismiss()
|
||||
warnKeyOverwrite(friendId) {
|
||||
askForKeys(conversationId)
|
||||
val hideConversationToolboxUi by context.config.experimental.e2eEncryption.hideConversationToolboxUi
|
||||
|
||||
if (!hideConversationToolboxUi) {
|
||||
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
|
||||
context.database.getDMOtherParticipant(it) != null
|
||||
}) { dialog, conversationId ->
|
||||
val friendId = remember {
|
||||
context.database.getDMOtherParticipant(conversationId)
|
||||
} ?: return@addComposable
|
||||
val fingerprint = remember {
|
||||
runCatching {
|
||||
e2eeInterface.getSecretFingerprint(friendId)
|
||||
}.getOrNull()
|
||||
}
|
||||
if (fingerprint != null) {
|
||||
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
|
||||
} else {
|
||||
Text(translation["toolbox.no_shared_key"])
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Button(onClick = {
|
||||
dialog.dismiss()
|
||||
warnKeyOverwrite(friendId) {
|
||||
askForKeys(conversationId)
|
||||
}
|
||||
}) {
|
||||
Text(translation["toolbox.initiate_exchange_button"])
|
||||
}
|
||||
}) {
|
||||
Text(translation["toolbox.initiate_exchange_button"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +286,7 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.chatMessage { conversationId, messageId ->
|
||||
val viewGroup = event.view.findParent(maxIteration = 3) {
|
||||
it is LinearLayout
|
||||
} as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage
|
||||
val viewGroup = resolveKeyActionContainer(event.view) ?: return@chatMessage
|
||||
|
||||
viewGroup.findViewWithTag<View>(specialCard)?.also {
|
||||
viewGroup.removeView(it)
|
||||
@@ -289,27 +309,45 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
val publicKey = pkRequests[messageId.toLong()]
|
||||
|
||||
if (publicKey != null || secret != null) {
|
||||
viewGroup.addView(createComposeView(context.mainActivity!!) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
onClick = {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
) {
|
||||
createComposeView(viewGroup.context) {
|
||||
PurrfectOverlayTheme {
|
||||
val actionShape = RoundedCornerShape(22.dp)
|
||||
val borderBrush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.70f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(translation["accept_public_key_button"])
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(translation["accept_secret_button"])
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(actionShape)
|
||||
.background(PurrfectOverlayPalette.cardOverlay, actionShape)
|
||||
.border(1.15.dp, borderBrush, actionShape)
|
||||
.padding(horizontal = 18.dp, vertical = 11.dp)
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(
|
||||
text = translation["accept_public_key_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(
|
||||
text = translation["accept_secret_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +357,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
})
|
||||
setOnClickListener {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
viewGroup.addView(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,6 +400,7 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
|
||||
val messageContent = event.messageContent
|
||||
val destinations = event.destinations
|
||||
if (messageContent.contentType != ContentType.CHAT) return@subscribe
|
||||
|
||||
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
|
||||
|
||||
@@ -384,10 +432,6 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
context.longToast(translation["encryption_failed_toast"])
|
||||
}
|
||||
}
|
||||
|
||||
if (event.messageContent.contentType == ContentType.SNAP) {
|
||||
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
@@ -36,28 +42,319 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getTypeArguments
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
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.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.lang.reflect.Method
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
|
||||
class MediaFilePicker : Feature("Media File Picker") {
|
||||
companion object {
|
||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||
private val queuedSplitItems = ArrayDeque<Any>()
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var reusableOriginalItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
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
|
||||
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
|
||||
fun setQueuedOverrideType(value: String?) {
|
||||
queuedOverrideType = value
|
||||
}
|
||||
fun getQueuedOverrideType(): String? = queuedOverrideType
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
}
|
||||
fun sendReusableOriginalItem(): Boolean {
|
||||
val item = reusableOriginalItem ?: return false
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
|
||||
clearQueuedSplitItems(deleteTempItems = false)
|
||||
originalUnsplitItem = originalItem
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
val item = originalUnsplitItem ?: return false
|
||||
val overrideType = queuedOverrideType
|
||||
clearQueuedSplitItems(deleteTempItems = true)
|
||||
queuedOverrideType = overrideType
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val next = queuedSplitItems.removeFirstOrNull() ?: run {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
val result = sender(next)
|
||||
if (!result) {
|
||||
queuedSplitItems.addFirst(next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var lastMediaDuration: Long? = null
|
||||
private set
|
||||
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
inputFile: File,
|
||||
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
|
||||
): List<File> {
|
||||
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
|
||||
if (durationMs <= chunkDurationMs) return listOf(inputFile)
|
||||
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val rotation = runCatching {
|
||||
retriever.setDataSource(inputFile.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
||||
}.getOrDefault(0).also {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
|
||||
val outputFiles = mutableListOf<File>()
|
||||
var chunkStartMs = 0L
|
||||
var chunkIndex = 0
|
||||
|
||||
while (chunkStartMs < durationMs) {
|
||||
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
|
||||
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
|
||||
val extractor = MediaExtractor()
|
||||
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
val trackMap = mutableMapOf<Int, Int>()
|
||||
val chunkStartUs = chunkStartMs * 1000
|
||||
val chunkEndUs = chunkEndMs * 1000
|
||||
var muxerStarted = false
|
||||
var wroteAnySample = false
|
||||
|
||||
try {
|
||||
extractor.setDataSource(inputFile.absolutePath)
|
||||
|
||||
repeat(extractor.trackCount) { trackIndex ->
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
|
||||
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
|
||||
extractor.selectTrack(trackIndex)
|
||||
trackMap[trackIndex] = muxer.addTrack(format)
|
||||
}
|
||||
|
||||
if (rotation != 0) {
|
||||
muxer.setOrientationHint(rotation)
|
||||
}
|
||||
|
||||
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
|
||||
extractor.getTrackFormat(trackIndex).let { format ->
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
1024 * 1024
|
||||
}
|
||||
}
|
||||
} ?: (1024 * 1024)
|
||||
|
||||
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
|
||||
val bufferInfo = android.media.MediaCodec.BufferInfo()
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
|
||||
while (true) {
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = extractor.readSampleData(buffer, 0)
|
||||
if (bufferInfo.size < 0) break
|
||||
|
||||
val sampleTimeUs = extractor.sampleTime
|
||||
if (sampleTimeUs < 0) break
|
||||
if (sampleTimeUs < chunkStartUs) {
|
||||
extractor.advance()
|
||||
continue
|
||||
}
|
||||
if (sampleTimeUs >= chunkEndUs) break
|
||||
|
||||
val sampleTrackIndex = extractor.sampleTrackIndex
|
||||
val muxerTrackIndex = trackMap[sampleTrackIndex]
|
||||
if (muxerTrackIndex != null) {
|
||||
bufferInfo.presentationTimeUs = sampleTimeUs - chunkStartUs
|
||||
bufferInfo.flags = extractor.sampleFlags
|
||||
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
|
||||
wroteAnySample = true
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
if (wroteAnySample) {
|
||||
outputFiles += outputFile
|
||||
} else {
|
||||
outputFile.delete()
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
outputFile.delete()
|
||||
outputFiles.forEach { it.delete() }
|
||||
throw throwable
|
||||
} finally {
|
||||
if (muxerStarted) {
|
||||
runCatching { muxer.stop() }
|
||||
}
|
||||
runCatching { muxer.release() }
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
|
||||
chunkStartMs += chunkDurationMs
|
||||
chunkIndex++
|
||||
}
|
||||
|
||||
return outputFiles
|
||||
}
|
||||
|
||||
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
|
||||
val resolver = context.androidContext.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("Failed to create MediaStore entry")
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("Failed to open MediaStore output stream")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
}
|
||||
}.onFailure {
|
||||
resolver.delete(uri, null, null)
|
||||
throw it
|
||||
}
|
||||
|
||||
val durationMs = extractMediaDuration(uri) ?: 0L
|
||||
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
|
||||
|
||||
context.coroutineScope.launch {
|
||||
delay(120_000)
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
return mediaItems.mapIndexedNotNull { index, mediaItem ->
|
||||
itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", mediaItem.durationMs.toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", mediaItem.itemId)
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", index.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
|
||||
val numericId = itemId.toLongOrNull() ?: return null
|
||||
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
|
||||
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
) ?: return null
|
||||
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
|
||||
|
||||
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
|
||||
|
||||
return runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
sourceFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Failed to open source gallery video")
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
chunkFiles.forEach { if (it != sourceFile) it.delete() }
|
||||
preparedItems
|
||||
}.also {
|
||||
sourceFile.delete()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to prepare split gallery items", it)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractMediaDuration(uri: Uri): Long? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return runCatching {
|
||||
@@ -96,6 +393,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
var sendItemsMethod: Method? = null
|
||||
var drawerViewClass: Class<*>? = null
|
||||
var sendItemsListItemClassFallback: Class<*>? = null
|
||||
var sendItemsHookedHandler: Any? = null
|
||||
|
||||
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
|
||||
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
|
||||
@@ -108,13 +406,79 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
val handlerParamMethod = contextType.methods.firstOrNull { method ->
|
||||
method.parameterTypes.size == 1 && (
|
||||
method.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler") ||
|
||||
actionHandlerCls.isAssignableFrom(method.parameterTypes[0])
|
||||
actionHandlerCls.isAssignableFrom(method.parameterTypes[0])
|
||||
)
|
||||
} ?: return@useMapper
|
||||
val sendItems = handlerParamMethod.parameterTypes[0].methods.firstOrNull { it.name == sendItemsName } ?: return@useMapper
|
||||
sendItemsMethod = sendItems
|
||||
handlerParamMethod.hook(HookStage.AFTER) {
|
||||
chatMediaDrawerActionHandler = it.arg(0)
|
||||
val handlerInstance = chatMediaDrawerActionHandler
|
||||
sendSingleItemHandler = sendSingleItem@{ item ->
|
||||
runCatching {
|
||||
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
true
|
||||
}.getOrElse { throwable ->
|
||||
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
sendItemsHookedHandler = handlerInstance
|
||||
|
||||
Hooker.hookObjectMethod(
|
||||
handlerInstance::class.java,
|
||||
handlerInstance,
|
||||
sendItemsName,
|
||||
HookStage.BEFORE
|
||||
) { param ->
|
||||
if (bypassSplitOnce) {
|
||||
bypassSplitOnce = false
|
||||
return@hookObjectMethod
|
||||
}
|
||||
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
|
||||
if (currentItems.isEmpty()) return@hookObjectMethod
|
||||
reusableOriginalItem = currentItems.firstOrNull()
|
||||
|
||||
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
?: currentItems.firstOrNull()?.javaClass
|
||||
?: return@hookObjectMethod
|
||||
|
||||
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
|
||||
var didExpand = false
|
||||
val expandedItems = currentItems.flatMap { item ->
|
||||
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
|
||||
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
|
||||
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
|
||||
?: 0L
|
||||
val itemId = baseItem.getObjectFieldOrNull("_itemId")
|
||||
?.getObjectFieldOrNull("_itemId")
|
||||
?.toString()
|
||||
?: return@flatMap listOf(item)
|
||||
|
||||
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
|
||||
if (splitItems.isNullOrEmpty()) {
|
||||
listOf(item)
|
||||
} else {
|
||||
didExpand = true
|
||||
preparedExpandedItems.addAll(splitItems)
|
||||
buildDrawerItems(itemClass, splitItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (didExpand && expandedItems.isNotEmpty()) {
|
||||
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
|
||||
param.setArg(1, listOf(expandedItems.first()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +491,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
val uri = param.arg<Uri>(0)
|
||||
if (!uri.toString().endsWith(firstVideoId.toString())) return@hook
|
||||
|
||||
param.setResult(object: CursorWrapper(param.getResult() as Cursor) {
|
||||
param.setResult(object : CursorWrapper(param.getResult() as Cursor) {
|
||||
override fun getLong(columnIndex: Int): Long {
|
||||
if (getColumnName(columnIndex) == "duration") {
|
||||
return lastMediaDuration ?: -1
|
||||
@@ -171,7 +535,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
fun sendMedia() {
|
||||
fun sendMedia(items: List<PreparedMediaItem>? = null) {
|
||||
val method = sendItemsMethod ?: return
|
||||
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
@@ -180,27 +544,13 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
|
||||
return
|
||||
}
|
||||
val item = itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", System.currentTimeMillis().toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", firstVideoId.toString())
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", 0.0)
|
||||
} ?: run {
|
||||
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
|
||||
val builtItems = buildDrawerItems(itemClass, mediaItems)
|
||||
if (builtItems.size != mediaItems.size) {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
|
||||
return
|
||||
}
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
|
||||
}
|
||||
|
||||
fun startConversion(audioOnly: Boolean) {
|
||||
@@ -238,8 +588,25 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
|
||||
|
||||
runCatching {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
|
||||
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
|
||||
convertedFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(convertedFile)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
|
||||
chunkFiles.forEach { if (it != convertedFile) it.delete() }
|
||||
convertedFile.delete()
|
||||
|
||||
sendMedia(preparedItems)
|
||||
} else {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
}
|
||||
}.onFailure {
|
||||
mediaInputStream = null
|
||||
context.log.error(it)
|
||||
@@ -269,7 +636,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.parent !is FrameLayout || drawerViewClass?.isInstance(event.view) != true) return@subscribe
|
||||
|
||||
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
|
||||
event.view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
if (event.parent.findViewWithTag<View>(buttonTag)?.run {
|
||||
visibility = View.VISIBLE
|
||||
@@ -345,6 +712,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE
|
||||
}
|
||||
@@ -352,5 +720,4 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
|
||||
import android.widget.ProgressBar
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageUpdate
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.util.CallbackBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
@@ -26,6 +40,54 @@ import kotlin.random.Random
|
||||
|
||||
class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") }
|
||||
private val markAsSeenBatchSize = 50
|
||||
private val markAsSeenBatchCooldownMs = 4000L
|
||||
|
||||
private data class PendingSnapMessage(
|
||||
val clientMessageId: Long,
|
||||
val creationTimestamp: Long
|
||||
)
|
||||
|
||||
private fun String?.isRateLimited(): Boolean {
|
||||
val value = this ?: return false
|
||||
return value.contains("RESOURCE_EXHAUSTED", ignoreCase = true) ||
|
||||
value.contains("Rate limited", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun showRateLimitedDialog(processed: Int, total: Int) {
|
||||
val activity = context.mainActivity ?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
"Rate limited after $processed/$total snaps. Try again later."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createComposeAlertDialog(activity) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
title = "Rate Limited",
|
||||
subtitle = "Snapchat stopped the mark-as-seen run to protect your account",
|
||||
icon = Icons.Default.WarningAmber
|
||||
) {
|
||||
Column(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Processed $processed of $total snaps before the request was rate limited.",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = "No bypass was attempted. Wait a bit and run it again, or lower the per-run limit in settings.",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun markConversationsAsRead(conversationIds: List<String>) {
|
||||
conversationIds.forEach { conversationId ->
|
||||
@@ -50,36 +112,123 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingSnapMessageIds(conversationId: String, requestedLimit: Int?): List<Long> {
|
||||
val collected = mutableListOf<PendingSnapMessage>()
|
||||
val pageSize = 200
|
||||
var page = 0
|
||||
|
||||
while (true) {
|
||||
val messages = context.database.getMessagesFromConversationId(conversationId, pageSize, page) ?: break
|
||||
messages.forEach { message ->
|
||||
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@forEach
|
||||
if (message.isViewedByUser == 1 || message.readTimestamp > 0L) return@forEach
|
||||
collected += PendingSnapMessage(
|
||||
clientMessageId = message.clientMessageId.toLong(),
|
||||
creationTimestamp = message.creationTimestamp
|
||||
)
|
||||
}
|
||||
if (messages.size < pageSize) break
|
||||
if (requestedLimit != null && collected.size >= requestedLimit) break
|
||||
page++
|
||||
}
|
||||
|
||||
return collected
|
||||
.distinctBy { it.clientMessageId }
|
||||
.sortedBy { it.creationTimestamp }
|
||||
.let { pending ->
|
||||
if (requestedLimit != null) pending.take(requestedLimit) else pending
|
||||
}
|
||||
.map { it.clientMessageId }
|
||||
}
|
||||
|
||||
fun markSnapsAsSeen(conversationId: String) {
|
||||
val messaging = context.feature(Messaging::class)
|
||||
val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
context.translation["mark_as_seen.no_unseen_snaps_toast"]
|
||||
)
|
||||
return
|
||||
val processingMode = context.config.messaging.markSnapAsSeenProcessingMode.get()
|
||||
val configuredLimit = context.config.messaging.markSnapAsSeenLimit.get()
|
||||
.coerceAtLeast(1)
|
||||
val requestedLimit = if (processingMode == "complete") null else configuredLimit
|
||||
val messageIds = getPendingSnapMessageIds(conversationId, requestedLimit)
|
||||
.ifEmpty {
|
||||
messaging.getFeedCachedMessageIds(conversationId)
|
||||
?.map { it.toLong() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { cached ->
|
||||
if (requestedLimit != null) cached.take(requestedLimit) else cached
|
||||
}
|
||||
?: run {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
context.translation["mark_as_seen.no_unseen_snaps_toast"]
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
val targetMessageIds = if (requestedLimit == null) {
|
||||
messageIds
|
||||
} else {
|
||||
messageIds.take(requestedLimit)
|
||||
}
|
||||
|
||||
var job: Job? = null
|
||||
val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
|
||||
.setTitle("Processing...")
|
||||
.setView(ProgressBar(context.mainActivity).apply {
|
||||
setPadding(10, 10, 10, 10)
|
||||
})
|
||||
.setOnDismissListener { job?.cancel() }
|
||||
.show()
|
||||
val processedCount = mutableIntStateOf(0)
|
||||
var rateLimitedAt: Int? = null
|
||||
val dialog = createComposeAlertDialog(context.mainActivity!!, builder = {
|
||||
setOnDismissListener { job?.cancel() }
|
||||
}) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
title = "Marking Snaps as Seen",
|
||||
subtitle = "Updating read state for queued snaps",
|
||||
icon = Icons.Default.Visibility
|
||||
) {
|
||||
Column(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = PurrfectOverlayPalette.glowSecondary,
|
||||
trackColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.18f)
|
||||
)
|
||||
Text(
|
||||
text = "${processedCount.intValue}/${targetMessageIds.size}",
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply { show() }
|
||||
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
messageIds.forEach { messageId ->
|
||||
markSnapAsSeen(conversationId, messageId)
|
||||
targetMessageIds.forEachIndexed { index, messageId ->
|
||||
val result = markSnapAsSeen(conversationId, messageId)
|
||||
if (result.isRateLimited()) {
|
||||
rateLimitedAt = processedCount.intValue
|
||||
return@launch
|
||||
}
|
||||
delay(Random.nextLong(20, 60))
|
||||
context.runOnUiThread {
|
||||
dialog.setTitle("Processing... (${messageIds.indexOf(messageId) + 1}/${messageIds.size})")
|
||||
processedCount.intValue = index + 1
|
||||
}
|
||||
val processed = index + 1
|
||||
if (processed < targetMessageIds.size && processed % markAsSeenBatchSize == 0) {
|
||||
delay(markAsSeenBatchCooldownMs)
|
||||
}
|
||||
}
|
||||
}.also { job = it }.invokeOnCompletion {
|
||||
context.runOnUiThread {
|
||||
dialog.dismiss()
|
||||
if (rateLimitedAt != null) {
|
||||
val processedIndex = rateLimitedAt!!
|
||||
processedCount.intValue = processedIndex
|
||||
showRateLimitedDialog(processedIndex, targetMessageIds.size)
|
||||
} else if (requestedLimit != null && targetMessageIds.size < messageIds.size) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.Info,
|
||||
"Processed ${targetMessageIds.size} of ${messageIds.size} unseen snaps."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,4 +302,4 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,28 @@ package me.eternal.purrfectsnap.core.features.impl.messaging
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
|
||||
@@ -16,22 +36,57 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
|
||||
private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) {
|
||||
if (motionEvent.action != MotionEvent.ACTION_UP) return
|
||||
param.setResult(true)
|
||||
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
|
||||
.setTitle(context.translation["call_start_confirmation.dialog_title"])
|
||||
.setMessage(context.translation["call_start_confirmation.dialog_message"])
|
||||
.setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() }
|
||||
.setNeutralButton(context.translation["button.negative"]) { _, _ -> }
|
||||
.show()
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = context.translation["call_start_confirmation.dialog_title"],
|
||||
subtitle = context.translation["call_start_confirmation.dialog_message"],
|
||||
icon = Icons.Default.Call
|
||||
) {
|
||||
val actionShape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.CenterHorizontally)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.width(120.dp),
|
||||
onClick = { alertDialog.dismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.negative"])
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.width(120.dp),
|
||||
onClick = {
|
||||
alertDialog.dismiss()
|
||||
onConfirm()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.26f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(context.translation["button.positive"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -57,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 ->
|
||||
@@ -68,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()
|
||||
}
|
||||
@@ -77,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))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ 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 androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.bridge.task.TaskListener
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
@@ -32,19 +35,26 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
|
||||
import me.eternal.purrfectsnap.core.messaging.MessageSender
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import me.eternal.purrfectsnap.core.util.CallbackBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Collections
|
||||
import java.util.IdentityHashMap
|
||||
import java.util.Locale
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
@@ -54,9 +64,45 @@ import kotlin.time.toDuration
|
||||
class SendOverride : Feature("Send Override") {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
|
||||
private val internalMultipartSend = ThreadLocal.withInitial { false }
|
||||
private var queuedOriginalItemRepeatCount = 0
|
||||
private var queuedOriginalItemRepeatOverrideType: String? = null
|
||||
|
||||
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
queuedOriginalItemRepeatOverrideType = overrideType
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
}
|
||||
|
||||
private fun clearQueuedOriginalItemRepeats() {
|
||||
queuedOriginalItemRepeatCount = 0
|
||||
queuedOriginalItemRepeatOverrideType = null
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
if (queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
return false
|
||||
}
|
||||
|
||||
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
return false
|
||||
}
|
||||
|
||||
queuedOriginalItemRepeatCount--
|
||||
MediaFilePicker.setQueuedOverrideType(overrideType)
|
||||
val result = MediaFilePicker.sendReusableOriginalItem()
|
||||
if (!result) {
|
||||
queuedOriginalItemRepeatCount++
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedType by mutableStateOf("SNAP")
|
||||
private var disableSplitForCurrentSend by mutableStateOf(false)
|
||||
private var customDuration by mutableFloatStateOf(10f)
|
||||
private var scheduledTime by mutableStateOf<Long?>(null)
|
||||
private var showClockPicker by mutableStateOf(false)
|
||||
@@ -66,7 +112,6 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
|
||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -362,7 +407,12 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
|
||||
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
|
||||
}
|
||||
|
||||
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
|
||||
if (internalMultipartSend.get() == true) return@subscribe
|
||||
postSavePolicy = null
|
||||
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
|
||||
val localMessageContent = event.messageContent
|
||||
@@ -394,6 +444,7 @@ class SendOverride : Feature("Send Override") {
|
||||
val recipientName = recipientNames.joinToString(", ")
|
||||
|
||||
event.canceled = true
|
||||
event.adapter.setResult(null)
|
||||
|
||||
fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) {
|
||||
val result = ev.adapter.invokeOriginal()
|
||||
@@ -401,9 +452,54 @@ class SendOverride : Feature("Send Override") {
|
||||
ev.canceled = false
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
val sendMessageCallbackClass by lazy {
|
||||
lateinit var result: Class<*>
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fun cloneDestinations(source: MessageDestinations): Any {
|
||||
return context.gson.fromJson(
|
||||
context.gson.toJson(source.instanceNonNull()),
|
||||
context.classCache.messageDestinations
|
||||
)
|
||||
}
|
||||
|
||||
val sendMessageWithContentMethod by lazy {
|
||||
sequence {
|
||||
var current: Class<*>? = context.classCache.conversationManager
|
||||
while (current != null && current != Any::class.java && current != Object::class.java) {
|
||||
yield(current)
|
||||
current = current.superclass
|
||||
}
|
||||
}.flatMap { it.declaredMethods.asSequence() }
|
||||
.first { it.name == "sendMessageWithContent" }
|
||||
}
|
||||
|
||||
val originalMessageJson = context.gson.toJson(localMessageContent.instanceNonNull())
|
||||
val originalCallback = event.adapter.args().getOrNull(2)
|
||||
val conversationManagerInstance by lazy {
|
||||
context.feature(Messaging::class).conversationManager?.instanceNonNull()
|
||||
}
|
||||
|
||||
fun invokeCallbackError(callback: Any?, error: Any?) {
|
||||
runCatching {
|
||||
callback?.javaClass?.methods?.firstOrNull { method ->
|
||||
method.name == "onError" && method.parameterCount == 1
|
||||
}?.invoke(callback, error)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyOverride(
|
||||
targetMessageContent: MessageContent,
|
||||
targetReader: ProtoReader,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?
|
||||
): Boolean {
|
||||
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
context.translation["gallery_media_send_override.multiple_media_toast"]
|
||||
@@ -416,10 +512,10 @@ class SendOverride : Feature("Send Override") {
|
||||
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
|
||||
postSavePolicy = savePolicyValue
|
||||
|
||||
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
|
||||
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
|
||||
|
||||
if (localMessageContent.contentType != ContentType.SNAP) {
|
||||
localMessageContent.content = ProtoWriter().apply {
|
||||
if (targetMessageContent.contentType != ContentType.SNAP) {
|
||||
targetMessageContent.content = ProtoWriter().apply {
|
||||
from(11) {
|
||||
from(5) {
|
||||
from(1) {
|
||||
@@ -440,11 +536,11 @@ class SendOverride : Feature("Send Override") {
|
||||
}.toByteArray()
|
||||
}
|
||||
|
||||
localMessageContent.contentType = ContentType.SNAP
|
||||
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
|
||||
targetMessageContent.contentType = ContentType.SNAP
|
||||
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
|
||||
edit(11, 5, 2) {
|
||||
arrayOf(6, 7, 8).forEach { remove(it) }
|
||||
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
|
||||
addVarInt(8, snapDurationMs / 1000)
|
||||
if (snapDurationMs / 1000 <= 0) {
|
||||
@@ -474,11 +570,11 @@ class SendOverride : Feature("Send Override") {
|
||||
if (shouldPreventSave) {
|
||||
postSavePolicy = 1 // PROHIBITED
|
||||
}
|
||||
localMessageContent.contentType = ContentType.NOTE
|
||||
targetMessageContent.contentType = ContentType.NOTE
|
||||
val stripMeta = context.config.messaging.stripMediaMetadata.get()
|
||||
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
|
||||
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
|
||||
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
|
||||
val audioNoteProto = MessageSender.audioNoteProto(
|
||||
@@ -487,7 +583,7 @@ class SendOverride : Feature("Send Override") {
|
||||
)
|
||||
|
||||
// Set save policy in the proto if prevent audio is enabled
|
||||
localMessageContent.content = if (shouldPreventSave) {
|
||||
targetMessageContent.content = if (shouldPreventSave) {
|
||||
// Check which path structure exists in the audio note proto
|
||||
val protoReader = ProtoReader(audioNoteProto)
|
||||
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
|
||||
@@ -519,7 +615,7 @@ class SendOverride : Feature("Send Override") {
|
||||
Class.forName(
|
||||
"com.snapchat.client.messaging.SavePolicy",
|
||||
false,
|
||||
localMessageContent.instanceNonNull().javaClass.classLoader
|
||||
targetMessageContent.instanceNonNull().javaClass.classLoader
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -537,7 +633,7 @@ class SendOverride : Feature("Send Override") {
|
||||
}.getOrNull()
|
||||
|
||||
if (policyEnum != null) {
|
||||
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,17 +645,228 @@ class SendOverride : Feature("Send Override") {
|
||||
return true
|
||||
}
|
||||
|
||||
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
|
||||
fun createMessageContentFromOriginal(): MessageContent {
|
||||
return MessageContent(
|
||||
context.gson.fromJson(originalMessageJson, context.classCache.localMessageContent)
|
||||
).also { messageContent ->
|
||||
val visited = Collections.newSetFromMap(IdentityHashMap<Any, Boolean>())
|
||||
|
||||
fun shouldScrubField(fieldName: String): Boolean {
|
||||
if (fieldName == "mId") return false
|
||||
return fieldName in setOf("mMessageId", "mQuotedMessageId") ||
|
||||
fieldName.contains("AttemptId", ignoreCase = true) ||
|
||||
fieldName.contains("ClientMessageId", ignoreCase = true) ||
|
||||
fieldName.contains("ClientId", ignoreCase = true) ||
|
||||
fieldName.contains("MessageUuid", ignoreCase = true) ||
|
||||
fieldName.contains("UUID", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun scrubValue(value: Any?) {
|
||||
if (value == null) return
|
||||
if (!visited.add(value)) return
|
||||
|
||||
when (value) {
|
||||
is String, is Number, is Boolean, is ByteArray, is Enum<*> -> return
|
||||
is Iterable<*> -> {
|
||||
value.forEach { scrubValue(it) }
|
||||
return
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
value.values.forEach { scrubValue(it) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sequence<Class<*>> {
|
||||
var current: Class<*>? = value.javaClass
|
||||
while (current != null && current != Any::class.java && current != Object::class.java) {
|
||||
yield(current)
|
||||
current = current.superclass
|
||||
}
|
||||
}.flatMap { it.declaredFields.asSequence() }
|
||||
.forEach { field ->
|
||||
runCatching {
|
||||
field.isAccessible = true
|
||||
if (shouldScrubField(field.name)) {
|
||||
when (field.type) {
|
||||
java.lang.Long.TYPE -> field.setLong(value, 0L)
|
||||
java.lang.Integer.TYPE -> field.setInt(value, 0)
|
||||
java.lang.Boolean.TYPE -> field.setBoolean(value, false)
|
||||
else -> field.set(value, null)
|
||||
}
|
||||
} else {
|
||||
scrubValue(field.get(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrubValue(messageContent.instanceNonNull())
|
||||
}
|
||||
}
|
||||
|
||||
fun invokeSendManually(messageContent: MessageContent, callback: Any?) {
|
||||
val conversationManager = conversationManagerInstance ?: error("ConversationManager is null")
|
||||
internalMultipartSend.set(true)
|
||||
try {
|
||||
sendMessageWithContentMethod.invoke(
|
||||
conversationManager,
|
||||
cloneDestinations(event.destinations),
|
||||
messageContent.instanceNonNull(),
|
||||
callback
|
||||
)
|
||||
} finally {
|
||||
internalMultipartSend.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMediaManual(
|
||||
sourceMessageContent: MessageContent,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?,
|
||||
completionCallback: Any?
|
||||
): Boolean {
|
||||
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
|
||||
fun buildPartMessageContent(partIndex: Int): MessageContent {
|
||||
val partContent = createMessageContentFromOriginal()
|
||||
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
|
||||
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
|
||||
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
partContent.content = ProtoEditor(partContent.content!!).apply {
|
||||
edit(3) {
|
||||
remove(3)
|
||||
addBuffer(3, mediaBuffers[partIndex])
|
||||
}
|
||||
}.toByteArray()
|
||||
if (partIndex < refs.size) {
|
||||
partContent.localMediaReferences = arrayListOf(refs[partIndex])
|
||||
}
|
||||
metadata?.let {
|
||||
if (contentRefs != null && partIndex < contentRefs.size) {
|
||||
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
|
||||
}
|
||||
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
|
||||
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
|
||||
}
|
||||
}
|
||||
return partContent
|
||||
}
|
||||
|
||||
fun sendPart(partIndex: Int) {
|
||||
postSavePolicy = null
|
||||
val partContent = buildPartMessageContent(partIndex)
|
||||
val partReader = ProtoReader(partContent.content ?: return)
|
||||
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
|
||||
|
||||
val callback = if (partIndex == mediaCount - 1) {
|
||||
completionCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendPart(partIndex + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
invokeSendManually(partContent, callback)
|
||||
}
|
||||
|
||||
sendPart(0)
|
||||
return true
|
||||
}
|
||||
|
||||
postSavePolicy = null
|
||||
val targetReader = ProtoReader(sourceMessageContent.content ?: return false)
|
||||
if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false
|
||||
invokeSendManually(sourceMessageContent, completionCallback)
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendRepeatedMediaManual(
|
||||
repeatCount: Int,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?
|
||||
): Boolean {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendIteration(index + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val preparedContent = createMessageContentFromOriginal()
|
||||
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
|
||||
invokeCallbackError(originalCallback, "Failed to send")
|
||||
}
|
||||
}
|
||||
|
||||
sendIteration(0)
|
||||
return true
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
postSavePolicy = null
|
||||
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
?: configOverrideType?.takeIf { it != "always_ask" }
|
||||
|
||||
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
|
||||
sendEvent.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
|
||||
val handledRepeat = if (!handledSplit) {
|
||||
handleQueuedOriginalItemRepeatSuccess()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
if (!handledSplit && !handledRepeat) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
}
|
||||
}
|
||||
sendEvent.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedOverrideType != null) {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
context.runOnUiThread {
|
||||
val recipientNameForTask = recipientName
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
|
||||
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
|
||||
PurrfectOverlayTheme {
|
||||
@@ -649,6 +956,8 @@ class SendOverride : Feature("Send Override") {
|
||||
context.translation.getCategory("features.options.gallery_media_send_override")
|
||||
}
|
||||
var scheduleEnabled by remember { mutableStateOf(false) }
|
||||
var continuousSendEnabled by remember { mutableStateOf(false) }
|
||||
var continuousSendCount by remember { mutableStateOf("2") }
|
||||
|
||||
Text(
|
||||
fontSize = 20.sp,
|
||||
@@ -713,6 +1022,21 @@ class SendOverride : Feature("Send Override") {
|
||||
fun toggleSaveable() {
|
||||
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
disableSplitForCurrentSend = !disableSplitForCurrentSend
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = disableSplitForCurrentSend,
|
||||
onCheckedChange = {
|
||||
disableSplitForCurrentSend = it
|
||||
}
|
||||
)
|
||||
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
toggleSaveable()
|
||||
@@ -749,6 +1073,42 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
continuousSendEnabled = !continuousSendEnabled
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = continuousSendEnabled,
|
||||
onCheckedChange = {
|
||||
continuousSendEnabled = it
|
||||
}
|
||||
)
|
||||
Text(text = mainTranslation["continuous_send_toggle"], lineHeight = 15.sp)
|
||||
}
|
||||
|
||||
if (continuousSendEnabled) {
|
||||
OutlinedTextField(
|
||||
value = continuousSendCount,
|
||||
onValueChange = { value ->
|
||||
continuousSendCount = value.filter(Char::isDigit).take(3)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text(mainTranslation["continuous_send_count_label"]) },
|
||||
placeholder = { Text(mainTranslation["continuous_send_count_placeholder"]) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions.Default
|
||||
)
|
||||
Text(
|
||||
text = mainTranslation["continuous_send_hint"],
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.72f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
@@ -913,8 +1273,46 @@ class SendOverride : Feature("Send Override") {
|
||||
Text(context.translation["button.cancel"])
|
||||
}
|
||||
Button(onClick = {
|
||||
alertDialog.dismiss()
|
||||
val finalSelectedType = selectedType
|
||||
val repeatCount = if (continuousSendEnabled) {
|
||||
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
|
||||
} else {
|
||||
1
|
||||
}
|
||||
if (repeatCount == null) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
text = mainTranslation["continuous_send_invalid_count"]
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
if (repeatCount > 1 && disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
text = mainTranslation["continuous_send_single_send_conflict"]
|
||||
)
|
||||
return@Button
|
||||
}
|
||||
alertDialog.dismiss()
|
||||
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(null)
|
||||
}
|
||||
return@Button
|
||||
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
event.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
|
||||
if (delayMs != null && delayMs > 0) {
|
||||
val taskHash = java.util.UUID.randomUUID().toString()
|
||||
@@ -960,8 +1358,11 @@ class SendOverride : Feature("Send Override") {
|
||||
|
||||
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
|
||||
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
)) {
|
||||
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
@@ -1008,8 +1409,24 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (repeatCount == 1) {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
} else if (MediaFilePicker.hasReusableOriginalItem()) {
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
} else {
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -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,13 @@ 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) {
|
||||
peekingStateListeners.add(listener)
|
||||
}
|
||||
|
||||
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
|
||||
return runCatching {
|
||||
@@ -99,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)
|
||||
@@ -128,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
|
||||
@@ -184,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 -> {
|
||||
@@ -201,9 +231,43 @@ 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 -> {}
|
||||
}
|
||||
|
||||
when (eventType) {
|
||||
TrackerEventType.STARTED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, true) }
|
||||
TrackerEventType.STOPPED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, false) }
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -255,13 +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],
|
||||
peeking = stateMap[8]
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,7 +456,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
|
||||
override fun init() {
|
||||
val sessionEventsConfig = context.config.friendTracker
|
||||
if (sessionEventsConfig.globalState != true) return
|
||||
val shouldProcessSessionEvents = sessionEventsConfig.globalState == true || peekingStateListeners.isNotEmpty()
|
||||
if (!shouldProcessSessionEvents) return
|
||||
|
||||
if (sessionEventsConfig.allowRunningInBackground.get()) {
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
@@ -402,7 +474,7 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionEventsConfig.recordMessagingEvents.get()) {
|
||||
if (sessionEventsConfig.recordMessagingEvents.get() || peekingStateListeners.isNotEmpty()) {
|
||||
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
|
||||
hook("onReceive", HookStage.BEFORE) { param ->
|
||||
param.setResult(null)
|
||||
|
||||
@@ -6,17 +6,10 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
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.getObjectField
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
private val peekingConversations = ConcurrentHashMap<String, List<String>>()
|
||||
private val startPeekingTimestamps = ConcurrentHashMap<String, Long>()
|
||||
private val startPeekingTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private val halfSwipeListeners = mutableListOf<(String, String, Long) -> Unit>()
|
||||
|
||||
private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
@@ -39,44 +32,11 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.messaging.halfSwipeNotifier.globalState != true) return
|
||||
lateinit var presenceService: Any
|
||||
|
||||
findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) {
|
||||
presenceService = it.thisObject()
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
callbacks.getClass("PresenceServiceDelegate")?.hook("notifyActiveConversationsChanged", HookStage.BEFORE) {
|
||||
val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants)
|
||||
|
||||
if (activeConversations.isEmpty()) {
|
||||
peekingConversations.forEach {
|
||||
val conversationId = it.key
|
||||
val peekingParticipantsIds = it.value
|
||||
peekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
peekingConversations.clear()
|
||||
return@hook
|
||||
}
|
||||
|
||||
activeConversations.forEach { (conversationId, conversationInfo) ->
|
||||
val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach
|
||||
val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList()
|
||||
|
||||
val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet()
|
||||
val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet()
|
||||
|
||||
newPeekingParticipantsIds.forEach { userId ->
|
||||
startPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
|
||||
exitedPeekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
peekingConversations[conversationId.toString()] = peekingParticipantsIds
|
||||
}
|
||||
context.feature(FriendTracker::class).addOnPeekingStateChangedListener { conversationId, userId, peeking ->
|
||||
if (peeking) {
|
||||
startPeeking(conversationId, userId)
|
||||
} else {
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,4 +99,4 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,66 +1,173 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageUpdate
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
import me.eternal.purrfectsnap.common.util.ktx.findFieldsToString
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
|
||||
import me.eternal.purrfectsnap.core.ui.getValdiContext
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
|
||||
import me.eternal.purrfectsnap.mapper.impl.ChatEventDispatcherMapper
|
||||
|
||||
class DoubleTapChatAction: Feature("Double Tap Chat Action") {
|
||||
private data class ChatDoubleTapTarget(
|
||||
val conversationId: String,
|
||||
val messageId: Long
|
||||
)
|
||||
|
||||
private val messageIdPattern = Regex("([0-9a-fA-F-]{36}):[^,\\s:]+:(\\d+)")
|
||||
private var lastTapTarget: ChatDoubleTapTarget? = null
|
||||
private var lastTapAt = 0L
|
||||
private var lastTapDownTime = -1L
|
||||
private var lastHandledTarget: ChatDoubleTapTarget? = null
|
||||
private var lastHandledAt = 0L
|
||||
|
||||
private fun resolveTarget(rawValue: String?): ChatDoubleTapTarget? {
|
||||
val match = rawValue?.let { messageIdPattern.find(it) } ?: return null
|
||||
val conversationId = match.groupValues[1]
|
||||
val messageId = match.groupValues[2].toLongOrNull() ?: return null
|
||||
val message = context.database.getConversationMessageFromId(messageId) ?: return null
|
||||
if (message.clientConversationId != conversationId) return null
|
||||
return ChatDoubleTapTarget(conversationId, messageId)
|
||||
}
|
||||
|
||||
private fun resolveTargetFromDispatcherEvent(event: Any): ChatDoubleTapTarget? {
|
||||
resolveTarget(event.toString())?.let { return it }
|
||||
val field = event.javaClass.findFieldsToString(event, once = true) { _, value ->
|
||||
value.contains("ChatViewModel") || messageIdPattern.containsMatchIn(value)
|
||||
}.firstOrNull() ?: return null
|
||||
return resolveTarget(field.get(event)?.toString())
|
||||
}
|
||||
|
||||
private fun resolveTargetFromView(view: View): ChatDoubleTapTarget? {
|
||||
val valdiContext = view.getValdiContext() ?: return null
|
||||
return sequenceOf(
|
||||
valdiContext.viewModel,
|
||||
valdiContext.viewModelLegacy,
|
||||
valdiContext.componentContext?.get()
|
||||
).mapNotNull { candidate ->
|
||||
resolveTarget(candidate?.toString())
|
||||
}.firstOrNull()
|
||||
}
|
||||
|
||||
private fun executeAction(action: String, target: ChatDoubleTapTarget) {
|
||||
if (
|
||||
lastHandledTarget == target &&
|
||||
SystemClock.uptimeMillis() - lastHandledAt <= ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastHandledTarget = target
|
||||
lastHandledAt = SystemClock.uptimeMillis()
|
||||
|
||||
if (action == "like_message") {
|
||||
context.feature(Messaging::class).conversationManager?.reactToMessage(
|
||||
target.conversationId,
|
||||
target.messageId,
|
||||
intentionType = 1L,
|
||||
onError = {},
|
||||
onSuccess = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (action == "copy_text") {
|
||||
val messageContent = context.database.getConversationMessageFromId(target.messageId)?.messageContent ?: return
|
||||
val proto = ProtoReader(messageContent).followPath(4, 4) ?: return
|
||||
context.androidContext.copyToClipboard(
|
||||
proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return,
|
||||
"Chat Message"
|
||||
)
|
||||
}
|
||||
|
||||
if (action == "delete_message") {
|
||||
context.feature(Messaging::class).conversationManager?.updateMessage(
|
||||
target.conversationId,
|
||||
target.messageId,
|
||||
MessageUpdate.ERASE,
|
||||
onResult = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (action == "mark_as_read") {
|
||||
val message = context.database.getConversationMessageFromId(target.messageId) ?: return
|
||||
when (ContentType.fromId(message.contentType)) {
|
||||
ContentType.SNAP,
|
||||
ContentType.TINY_SNAP,
|
||||
ContentType.EXTERNAL_MEDIA -> {
|
||||
context.coroutineScope.launch {
|
||||
context.feature(AutoMarkAsRead::class).markSnapAsSeen(target.conversationId, target.messageId)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
context.feature(StealthMode::class).addDisplayedMessageException(target.messageId)
|
||||
context.feature(Messaging::class).conversationManager?.displayedMessages(
|
||||
target.conversationId,
|
||||
target.messageId,
|
||||
onResult = {
|
||||
if (it != null) {
|
||||
context.log.error("Failed to mark conversation as read: $it")
|
||||
context.shortToast(context.translation["toast_mark_conversation_read_failed"])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action == "custom_emoji_reaction") {
|
||||
context.feature(Messaging::class).conversationManager?.reactToMessage(
|
||||
target.conversationId,
|
||||
target.messageId,
|
||||
emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D",
|
||||
onError = {},
|
||||
onSuccess = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
var action = context.config.messaging.doubleTapChatAction.getNullable() ?: return
|
||||
|
||||
context.mappings.useMapper(ChatEventDispatcherMapper::class) {
|
||||
classReference.getAsClass()?.hook("onChatItemDoubleClickEvent", HookStage.BEFORE) { param ->
|
||||
param.setResult(null)
|
||||
val event = param.arg<Any>(0)
|
||||
val viewModel = event.javaClass.findFieldsToString(event, once = true) { field, value -> value.contains("ChatViewModel") }.firstOrNull()?.get(event)?.toString() ?: return@hook
|
||||
|
||||
val (conversationId, _, clientMessageId) = viewModel.substringAfter("messageId=").substringBefore(",").split(":").takeIf { it.size == 3 } ?: return@hook
|
||||
|
||||
val messageId = clientMessageId.toLongOrNull() ?: return@hook
|
||||
|
||||
if (action == "like_message") {
|
||||
context.feature(Messaging::class).conversationManager?.reactToMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
intentionType = 1L,
|
||||
onError = {},
|
||||
onSuccess = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (action == "copy_text") {
|
||||
var messageContent = context.database.getConversationMessageFromId(messageId)?.messageContent ?: return@hook
|
||||
var proto = ProtoReader(messageContent).followPath(4, 4) ?: return@hook
|
||||
context.androidContext.copyToClipboard(proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return@hook, "Chat Message")
|
||||
}
|
||||
|
||||
if (action == "delete_message" || action == "mark_as_read") {
|
||||
context.feature(Messaging::class).conversationManager?.updateMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
if (action == "delete_message") MessageUpdate.ERASE else MessageUpdate.READ,
|
||||
onResult = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (action == "custom_emoji_reaction") {
|
||||
context.feature(Messaging::class).conversationManager?.reactToMessage(
|
||||
conversationId,
|
||||
messageId,
|
||||
emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D",
|
||||
onError = {},
|
||||
onSuccess = {}
|
||||
)
|
||||
}
|
||||
resolveTargetFromDispatcherEvent(param.arg(0))?.let { executeAction(action, it) }
|
||||
}
|
||||
}
|
||||
|
||||
View::class.java.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
|
||||
val motionEvent = param.arg<MotionEvent>(0)
|
||||
if (motionEvent.actionMasked != MotionEvent.ACTION_UP) return@hook
|
||||
if (motionEvent.eventTime - motionEvent.downTime > ViewConfiguration.getTapTimeout()) return@hook
|
||||
if (lastTapDownTime == motionEvent.downTime) return@hook
|
||||
|
||||
val target = resolveTargetFromView(param.thisObject()) ?: return@hook
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val isSecondTap = lastTapTarget == target &&
|
||||
now - lastTapAt <= ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
|
||||
lastTapDownTime = motionEvent.downTime
|
||||
if (isSecondTap) {
|
||||
executeAction(action, target)
|
||||
lastTapTarget = null
|
||||
lastTapAt = 0L
|
||||
return@hook
|
||||
}
|
||||
|
||||
lastTapTarget = target
|
||||
lastTapAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaCodec
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
|
||||
class VideoRecordTimer : Feature("Video Record Timer") {
|
||||
override fun init() {
|
||||
if (!context.config.camera.videoRecordTimer.get()) return
|
||||
|
||||
val activeComponents = mutableSetOf<Int>()
|
||||
|
||||
fun startRecording(componentHashCode: Int) {
|
||||
synchronized(activeComponents) {
|
||||
if (activeComponents.isEmpty()) {
|
||||
context.inAppOverlay.videoRecordTimerState.isRecording = true
|
||||
context.inAppOverlay.videoRecordTimerState.recordingStartTime = System.currentTimeMillis() - 1000
|
||||
}
|
||||
activeComponents.add(componentHashCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording(componentHashCode: Int) {
|
||||
synchronized(activeComponents) {
|
||||
activeComponents.remove(componentHashCode)
|
||||
if (activeComponents.isEmpty()) {
|
||||
context.inAppOverlay.videoRecordTimerState.isRecording = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("startRecording", HookStage.AFTER) {
|
||||
startRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("stop", HookStage.BEFORE) {
|
||||
stopRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("release", HookStage.BEFORE) {
|
||||
stopRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("start", HookStage.AFTER) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
startRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("stop", HookStage.BEFORE) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
stopRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("release", HookStage.BEFORE) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
stopRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.content.res.TypedArray
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
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.getObjectField
|
||||
|
||||
class CustomTheming : Feature("Custom Theming") {
|
||||
@@ -50,37 +53,80 @@ class CustomTheming : Feature("Custom Theming") {
|
||||
).any { it in name }
|
||||
}
|
||||
|
||||
private fun patchTypedArray(result: TypedArray, attrIds: IntArray?) {
|
||||
val requestedAttrs = attrIds?.takeIf { it.isNotEmpty() } ?: return
|
||||
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return
|
||||
val stride = (typedArrayData.size / requestedAttrs.size).takeIf { it >= 2 } ?: return
|
||||
|
||||
requestedAttrs.forEachIndexed { index, attrId ->
|
||||
val offset = index * stride
|
||||
if (offset + 1 >= typedArrayData.size) return@forEachIndexed
|
||||
|
||||
val type = typedArrayData[offset]
|
||||
if (type !in colorTypes) return@forEachIndexed
|
||||
|
||||
val originalColor = runCatching { result.getColor(index, Int.MIN_VALUE) }.getOrNull()
|
||||
?.takeIf { it != Int.MIN_VALUE }
|
||||
?: return@forEachIndexed
|
||||
|
||||
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
|
||||
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
|
||||
if (!shouldPatch) return@forEachIndexed
|
||||
|
||||
typedArrayData[offset + 1] = amoledBlack
|
||||
if (patchedAttrIds.add(attrId)) {
|
||||
context.log.verbose(
|
||||
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchProgrammaticColor(color: Int): Int {
|
||||
return if (isNearBlackOpaque(color) && color != amoledBlack) amoledBlack else color
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.userInterface.forceAmoledTheme.get()) return
|
||||
|
||||
onNextActivityCreate {
|
||||
context.androidContext.theme.javaClass
|
||||
.getMethod("obtainStyledAttributes", IntArray::class.java)
|
||||
.hook(HookStage.AFTER) { param ->
|
||||
val array = param.arg<IntArray>(0)
|
||||
val attrId = array[0]
|
||||
val result = param.getResult() as TypedArray
|
||||
val type = result.getType(0)
|
||||
if (type !in colorTypes) return@hook
|
||||
|
||||
val originalColor = runCatching { result.getColor(0, Int.MIN_VALUE) }.getOrNull()
|
||||
?.takeIf { it != Int.MIN_VALUE }
|
||||
?: return@hook
|
||||
|
||||
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
|
||||
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
|
||||
if (!shouldPatch) return@hook
|
||||
|
||||
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return@hook
|
||||
if (typedArrayData.size < 2) return@hook
|
||||
|
||||
typedArrayData[1] = amoledBlack
|
||||
if (patchedAttrIds.add(attrId)) {
|
||||
context.log.verbose(
|
||||
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
|
||||
)
|
||||
}
|
||||
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
|
||||
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
|
||||
val result = param.getResult() as? TypedArray ?: return@hook
|
||||
patchTypedArray(result, requestedAttrs)
|
||||
}
|
||||
|
||||
context.androidContext.javaClass
|
||||
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
|
||||
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
|
||||
val result = param.getResult() as? TypedArray ?: return@hook
|
||||
patchTypedArray(result, requestedAttrs)
|
||||
}
|
||||
|
||||
View::class.java.hook("setBackgroundColor", HookStage.BEFORE) { param ->
|
||||
val color = param.argNullable<Int>(0) ?: return@hook
|
||||
val patched = patchProgrammaticColor(color)
|
||||
if (patched != color) {
|
||||
param.setArg(0, patched)
|
||||
}
|
||||
}
|
||||
|
||||
ColorDrawable::class.java.hookConstructor(HookStage.BEFORE) { param ->
|
||||
val color = param.argNullable<Int>(0) ?: return@hookConstructor
|
||||
val patched = patchProgrammaticColor(color)
|
||||
if (patched != color) {
|
||||
param.setArg(0, patched)
|
||||
}
|
||||
}
|
||||
|
||||
ColorDrawable::class.java.hook("setColor", HookStage.BEFORE) { param ->
|
||||
val color = param.argNullable<Int>(0) ?: return@hook
|
||||
val patched = patchProgrammaticColor(color)
|
||||
if (patched != color) {
|
||||
param.setArg(0, patched)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.widget.TextView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.getValdiContext
|
||||
import me.eternal.purrfectsnap.core.ui.getValdiViewNode
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiViewNode
|
||||
|
||||
class FakeSnapScore : Feature("Fake Snap Score") {
|
||||
|
||||
private fun findAllSnapTextViewsRecursive(node: ValdiViewNode, depth: Int = 0, result: MutableList<ValdiViewNode> = mutableListOf()): List<ValdiViewNode> {
|
||||
if (depth > 15) return result
|
||||
if (node.getClassName().endsWith("SnapTextView")) result.add(node)
|
||||
for (child in node.getChildren()) {
|
||||
findAllSnapTextViewsRecursive(child, depth + 1, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (context.config.userInterface.spoofSnapScore.globalState != true) return
|
||||
|
||||
val customScoreRaw = context.config.userInterface.spoofSnapScore.customSnapScore.getNullable()?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
|
||||
val customScore = try {
|
||||
val digitsOnly = customScoreRaw.replace(Regex("[^0-9]"), "")
|
||||
if (digitsOnly.isNotEmpty()) {
|
||||
val clampedVal = digitsOnly.toLong().coerceAtMost(9999999L)
|
||||
val formatted = StringBuilder()
|
||||
val reversed = clampedVal.toString().reversed()
|
||||
for (i in reversed.indices) {
|
||||
formatted.append(reversed[i])
|
||||
if ((i + 1) % 3 == 0 && i != reversed.lastIndex) {
|
||||
formatted.append(",")
|
||||
}
|
||||
}
|
||||
formatted.reverse().toString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: return
|
||||
|
||||
// Approach 1: AddViewEvent + Valdi setAttribute (score dialog when tapping pill)
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.viewClassName.endsWith("ProfileFlatlandmySnapScoreIdentityPillDialogView")) {
|
||||
event.view.post {
|
||||
event.view.getValdiContext()?.enqueueNextRenderCallback {
|
||||
val rootNode = event.view.getValdiViewNode() ?: return@enqueueNextRenderCallback
|
||||
val snapTextViews = findAllSnapTextViewsRecursive(rootNode)
|
||||
// Only spoof the blue pill (2nd), white shows original score
|
||||
snapTextViews.getOrNull(1)?.setAttribute("value", customScore)
|
||||
event.view.postInvalidate()
|
||||
}
|
||||
event.view.postInvalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approach 2: TextView.setText hook as fallback - text change only, no layout modifications
|
||||
onNextActivityCreate {
|
||||
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
|
||||
val text = param.argNullable<CharSequence>(0)?.toString() ?: return@hook
|
||||
if (!text.matches(Regex("^[0-9\\s,.]+$"))) return@hook
|
||||
|
||||
val digits = text.replace(Regex("[^0-9]"), "")
|
||||
if (digits.length < 4 && !text.contains(",")) return@hook
|
||||
|
||||
val textView = param.thisObject() as TextView
|
||||
var parent = textView.parent
|
||||
var isMyProfile = false
|
||||
var isFriendContext = false
|
||||
var isInScoreDialog = false
|
||||
|
||||
while (parent != null) {
|
||||
val fullName = parent.javaClass.name.lowercase()
|
||||
if (fullName.contains("friendsnapscore") || fullName.contains("friendprofile")) {
|
||||
isFriendContext = true
|
||||
break
|
||||
}
|
||||
if (fullName.contains("mysnapscore") || fullName.contains("myprofile")) {
|
||||
isMyProfile = true
|
||||
}
|
||||
if (fullName.contains("mysnapscoreidentitypilldialog")) isInScoreDialog = true
|
||||
parent = parent.parent
|
||||
}
|
||||
|
||||
// Only spoof blue pill in profile (not in dialog - AddViewEvent handles that). White always shows original.
|
||||
if (isMyProfile && !isFriendContext && !isInScoreDialog) {
|
||||
param.setArg(0, customScore)
|
||||
// Prevent ellipsis (...) when score is large - fix TextView and parent TextViews (white + blue pill)
|
||||
textView.post {
|
||||
val minW = textView.paint.measureText(customScore).toInt() + 80
|
||||
var current: android.view.View? = textView
|
||||
for (i in 0..4) {
|
||||
if (current == null) break
|
||||
if (current is TextView) {
|
||||
current.ellipsize = null
|
||||
current.maxWidth = Int.MAX_VALUE
|
||||
current.minWidth = minW
|
||||
current.minimumWidth = minW
|
||||
}
|
||||
current = current.parent as? android.view.View
|
||||
current?.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -137,4 +141,4 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,12 @@ class OperaStoryCounter : Feature("OperaStoryCounter") {
|
||||
override fun init() {
|
||||
val showCounter = this@OperaStoryCounter.context.config.userInterface.storyCounter.get()
|
||||
val showSourceIndicator = this@OperaStoryCounter.context.config.userInterface.storySourceIndicator.get()
|
||||
val storySnapJump = this@OperaStoryCounter.context.config.userInterface.storySnapJump.get()
|
||||
val storySnapListDownload = this@OperaStoryCounter.context.config.downloader.storySnapListDownload.get()
|
||||
val operaDownloadButton = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (!showCounter && !showSourceIndicator) return
|
||||
// OperaStoryOverlay handles counter/source/jump when any of these are enabled
|
||||
if (showCounter || showSourceIndicator || storySnapJump || storySnapListDownload || operaDownloadButton) return
|
||||
|
||||
this@OperaStoryCounter.context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
@@ -113,9 +117,14 @@ class OperaStoryCounter : Feature("OperaStoryCounter") {
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
val isOperaDownloadEnabled = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get()
|
||||
gravity = Gravity.TOP or if (isOperaDownloadEnabled) Gravity.START else Gravity.END
|
||||
topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(50)
|
||||
marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10)
|
||||
if (isOperaDownloadEnabled) {
|
||||
marginStart = this@OperaStoryCounter.context.userInterface.dpToPx(10)
|
||||
} else {
|
||||
marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewGroup.addView(composeView)
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Provides snap jump logic for Story Snap List Download batch downloads.
|
||||
* Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps.
|
||||
* Main overlay feature for story counter, source indicator, and Auto Skip (snap jump).
|
||||
* Also provides snap jump logic for Story Snap List Download batch downloads.
|
||||
*/
|
||||
class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private val overlayState = OperaStoryOverlayState()
|
||||
@@ -18,36 +51,173 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private lateinit var snapJump: OperaStorySnapJump
|
||||
|
||||
override fun init() {
|
||||
val showCounter = context.config.userInterface.storyCounter.get()
|
||||
val showSourceIndicator = context.config.userInterface.storySourceIndicator.get()
|
||||
val enableSnapJump = context.config.userInterface.storySnapJump.get()
|
||||
val storySnapListDownload = context.config.downloader.storySnapListDownload.get()
|
||||
val showDownloadButton = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (!storySnapListDownload) return
|
||||
if (!showCounter && !showSourceIndicator && !enableSnapJump && !storySnapListDownload && !showDownloadButton) return
|
||||
|
||||
snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() }
|
||||
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as FrameLayout
|
||||
if (event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as? ViewGroup ?: return@subscribe
|
||||
|
||||
val isWrapped = viewGroup is FrameLayout && viewGroup.childCount == 1 && viewGroup.getChildAt(0) is ViewGroup
|
||||
val actualLayer = if (isWrapped) viewGroup.getChildAt(0) as ViewGroup else viewGroup
|
||||
|
||||
if (viewGroup.findViewWithTag<View>("story_counter") != null ||
|
||||
event.parent.findViewWithTag<View>("story_counter") != null) return@subscribe
|
||||
event.parent.findViewWithTag<View>("story_counter") != null ||
|
||||
actualLayer.javaClass.name.endsWith("ScalableCircleMaskFrameLayout")
|
||||
) return@subscribe
|
||||
|
||||
if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe
|
||||
if (actualLayer.childCount > 0 && !actualLayer.javaClass.name.contains("OperaShapeView")) {
|
||||
storyFrameLayout = WeakReference(viewGroup as FrameLayout)
|
||||
viewGroup.tag = "story_counter"
|
||||
|
||||
storyFrameLayout = WeakReference(viewGroup)
|
||||
val composeView = createComposeView(viewGroup.context) {
|
||||
val counterText = overlayState.counterState.value
|
||||
val source = overlayState.sourceState.value
|
||||
val hasCounter = showCounter && counterText.isNotEmpty()
|
||||
val hasSource = showSourceIndicator && source.isNotEmpty()
|
||||
val currentIdx = overlayState.currentIndexState.intValue
|
||||
val totalCount = overlayState.totalCountState.intValue
|
||||
var showJumpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isDownloadButtonEnabled = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (hasCounter || hasSource || enableSnapJump || isDownloadButtonEnabled) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
if (hasCounter) {
|
||||
OperaStoryCounterDisplay(
|
||||
counterText = counterText,
|
||||
enableSnapJump = enableSnapJump,
|
||||
totalCount = totalCount,
|
||||
onCounterClick = { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (enableSnapJump && (hasCounter || totalCount > 1) && totalCount > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clickable { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSource) {
|
||||
if (hasCounter || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
}
|
||||
OperaStorySourceIndicatorDisplay(source = source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownloadButtonEnabled) {
|
||||
val mediaDownloader = remember { context.feature(MediaDownloader::class) }
|
||||
val snapSource = overlayState.snapSourceState.value
|
||||
val isInConversation = overlayState.isInConversationState.value
|
||||
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY" && !isInConversation) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Download,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.size(18.dp)
|
||||
.clickable {
|
||||
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableSnapJump && showJumpDialog && totalCount > 1) {
|
||||
OperaStorySnapJumpDialog(
|
||||
currentIndex = currentIdx,
|
||||
totalCount = totalCount,
|
||||
onDismiss = { showJumpDialog = false },
|
||||
onJump = { targetIndex ->
|
||||
showJumpDialog = false
|
||||
snapJump.jumpToSnap(targetIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeView.tag = "story_counter"
|
||||
composeView.layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
topMargin = this@OperaStoryOverlay.context.userInterface.dpToPx(50)
|
||||
marginEnd = this@OperaStoryOverlay.context.userInterface.dpToPx(10)
|
||||
}
|
||||
viewGroup.addView(composeView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
overlayState.setupDisplayStateHook(
|
||||
context = context,
|
||||
showCounter = false,
|
||||
showSourceIndicator = false,
|
||||
onSnapFullyDisplayed = {
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(it)
|
||||
showCounter = showCounter,
|
||||
showSourceIndicator = showSourceIndicator,
|
||||
onSnapFullyDisplayed = if (enableSnapJump) {
|
||||
{ currentIndex ->
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(currentIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClearState = { snapJump.removeJumpOverlay() }
|
||||
} else null,
|
||||
onClearState = if (enableSnapJump) { { snapJump.removeJumpOverlay() } } else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +225,46 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean =
|
||||
snapJump.requestJumpToSnap(targetIndex, totalCountOverride)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStoryCounterDisplay(
|
||||
counterText: String,
|
||||
enableSnapJump: Boolean,
|
||||
totalCount: Int,
|
||||
onCounterClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (counterText.isEmpty()) return
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.then(
|
||||
if (enableSnapJump && totalCount > 1)
|
||||
Modifier.clickable { onCounterClick() }
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = counterText,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStorySourceIndicatorDisplay(
|
||||
source: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (source.isEmpty()) return
|
||||
|
||||
val icon = if (source == "CAMERA") Icons.Outlined.CameraAlt else Icons.Outlined.PhotoLibrary
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = modifier.size(11.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ class OperaStoryOverlayState {
|
||||
val sourceState = mutableStateOf("")
|
||||
val currentIndexState = mutableIntStateOf(-1)
|
||||
val totalCountState = mutableIntStateOf(0)
|
||||
val snapSourceState = mutableStateOf<String?>(null)
|
||||
val isInConversationState = mutableStateOf(false)
|
||||
val storyIdentityState = mutableStateOf<String?>(null)
|
||||
|
||||
fun setupDisplayStateHook(
|
||||
context: ModContext,
|
||||
@@ -44,23 +47,14 @@ class OperaStoryOverlayState {
|
||||
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
|
||||
val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString()
|
||||
|
||||
if (mediaParamMap.containsKey("MESSAGE_ID")) {
|
||||
context.runOnUiThread {
|
||||
counterState.value = ""
|
||||
sourceState.value = ""
|
||||
currentIndexState.intValue = -1
|
||||
totalCountState.intValue = 0
|
||||
onClearState?.invoke()
|
||||
}
|
||||
return@hook
|
||||
}
|
||||
|
||||
if (snapSource == "SINGLE_SNAP_STORY") {
|
||||
if (mediaParamMap.containsKey("MESSAGE_ID") || snapSource == "SINGLE_SNAP_STORY") {
|
||||
context.runOnUiThread {
|
||||
counterState.value = ""
|
||||
sourceState.value = ""
|
||||
currentIndexState.intValue = -1
|
||||
totalCountState.intValue = 0
|
||||
snapSourceState.value = snapSource
|
||||
isInConversationState.value = mediaParamMap.containsKey("MESSAGE_ID")
|
||||
onClearState?.invoke()
|
||||
}
|
||||
return@hook
|
||||
@@ -70,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) {
|
||||
@@ -86,6 +88,9 @@ class OperaStoryOverlayState {
|
||||
sourceState.value = mediaOrigin
|
||||
currentIndexState.intValue = currentIndex ?: -1
|
||||
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()
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import me.eternal.purrfectsnap.common.ui.PurrfectOverlayPalette
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Composable dialog for jumping to a specific snap in the story (Auto Skip).
|
||||
* Matches SnapEnhance dialog size (75% width), transparency (0.88), and layout.
|
||||
* Styled with PurrfectSnap colors.
|
||||
*/
|
||||
@Composable
|
||||
fun OperaStorySnapJumpDialog(
|
||||
currentIndex: Int,
|
||||
totalCount: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onJump: (Int) -> Unit
|
||||
) {
|
||||
var sliderValue by remember { mutableFloatStateOf((currentIndex + 1).toFloat()) }
|
||||
val selectedSnap = sliderValue.roundToInt()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.background(
|
||||
color = PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.88f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$selectedSnap",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PurrfectOverlayPalette.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = " / $totalCount",
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
valueRange = 1f..totalCount.toFloat(),
|
||||
steps = if (totalCount > 2) totalCount - 2 else 0,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = PurrfectOverlayPalette.glowPrimary,
|
||||
activeTrackColor = PurrfectOverlayPalette.glowPrimary,
|
||||
activeTickColor = Color.Transparent,
|
||||
inactiveTrackColor = PurrfectOverlayPalette.textPrimary.copy(alpha = 0.12f),
|
||||
inactiveTickColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable { onDismiss() }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(
|
||||
color = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable {
|
||||
onDismiss()
|
||||
onJump(selectedSnap - 1)
|
||||
}
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Go",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap
|
||||
|
||||
private val fetchJobTab = randomTag()
|
||||
private val previewHorizontalAdjustmentDp = 16
|
||||
private val previewVerticalAdjustmentDp = 10
|
||||
|
||||
override fun init() {
|
||||
if (!context.config.userInterface.snapPreview.get()) return
|
||||
@@ -47,9 +49,11 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run {
|
||||
Triple(dpToPx(60), dpToPx(10), dpToPx(15))
|
||||
}
|
||||
val chatMediaCardHeight = context.userInterface.dpToPx(60)
|
||||
val chatMediaCardSnapMargin = context.userInterface.dpToPx(10)
|
||||
val chatMediaCardSnapMarginStartSdl = context.userInterface.dpToPx(15)
|
||||
val previewHorizontalAdjustment = context.userInterface.dpToPx(previewHorizontalAdjustmentDp)
|
||||
val previewVerticalAdjustment = context.userInterface.dpToPx(previewVerticalAdjustmentDp)
|
||||
|
||||
fun decodeMedia(file: File) = runCatching {
|
||||
bitmapCache.getOrPut(file.absolutePath) {
|
||||
@@ -91,8 +95,8 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
val bitmap = bitmapCache[mediaFilePath] ?: return
|
||||
|
||||
canvas.drawBitmap(bitmap,
|
||||
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
|
||||
(canvas.height - bitmap.height) / 2f,
|
||||
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat() + previewHorizontalAdjustment,
|
||||
(canvas.height - bitmap.height) / 2f + previewVerticalAdjustment,
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -101,4 +105,4 @@ class SnapPreview : Feature("SnapPreview") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,16 @@ class CallRecorderUIState {
|
||||
var lastInteractionTime by mutableStateOf(0L)
|
||||
}
|
||||
|
||||
class VideoRecordTimerState {
|
||||
var isRecording by mutableStateOf(false)
|
||||
var recordingStartTime by mutableStateOf(0L)
|
||||
}
|
||||
|
||||
class InAppOverlay(
|
||||
private val context: ModContext
|
||||
) {
|
||||
val callRecorderState = CallRecorderUIState()
|
||||
val videoRecordTimerState = VideoRecordTimerState()
|
||||
companion object {
|
||||
fun showCrashOverlay(content: String, throwable: Throwable? = null) {
|
||||
// deny network requests
|
||||
@@ -243,6 +249,79 @@ class InAppOverlay(
|
||||
}
|
||||
|
||||
CallRecorderOverlay()
|
||||
VideoRecordTimerOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoRecordTimerOverlay() {
|
||||
var elapsedTime by remember { mutableStateOf(0L) }
|
||||
|
||||
LaunchedEffect(videoRecordTimerState.isRecording) {
|
||||
if (videoRecordTimerState.isRecording) {
|
||||
while (videoRecordTimerState.isRecording) {
|
||||
delay(100)
|
||||
elapsedTime = System.currentTimeMillis() - videoRecordTimerState.recordingStartTime
|
||||
}
|
||||
} else {
|
||||
elapsedTime = 0L
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = videoRecordTimerState.isRecording,
|
||||
enter = fadeIn(animationSpec = tween(300)) + scaleIn(
|
||||
initialScale = 0.8f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
|
||||
targetScale = 0.8f,
|
||||
animationSpec = tween(200)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 45.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
val seconds = (elapsedTime / 1000) % 60
|
||||
val minutes = (elapsedTime / 1000) / 60
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseRatio by infiniteTransition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseRatio"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(Color.Red.copy(alpha = pulseRatio), CircleShape)
|
||||
)
|
||||
Text(
|
||||
text = String.format("%02d:%02d", minutes, seconds),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,16 +47,31 @@ fun View.addForegroundDrawable(tag: String, drawable: Drawable) {
|
||||
updateForegroundDrawable()
|
||||
}
|
||||
|
||||
fun View.triggerCloseTouchEvent() {
|
||||
arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach {
|
||||
this.dispatchTouchEvent(
|
||||
MotionEvent.obtain(
|
||||
SystemClock.uptimeMillis(),
|
||||
SystemClock.uptimeMillis(),
|
||||
it, 0f, 0f, 0
|
||||
)
|
||||
)
|
||||
}
|
||||
fun View.dispatchSyntheticTap(x: Float, y: Float, tapDurationMs: Long = 50L) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
|
||||
dispatchTouchEvent(downEvent)
|
||||
downEvent.recycle()
|
||||
|
||||
val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0)
|
||||
dispatchTouchEvent(upEvent)
|
||||
upEvent.recycle()
|
||||
}
|
||||
|
||||
fun View.triggerCloseTouchEvent(x: Float = 0f, y: Float = 0f, tapDurationMs: Long = 50L) {
|
||||
dispatchSyntheticTap(x, y, tapDurationMs)
|
||||
}
|
||||
|
||||
fun View.triggerCloseTouchEventAtFraction(
|
||||
xFraction: Float,
|
||||
yFraction: Float = 0.5f,
|
||||
tapDurationMs: Long = 50L
|
||||
) {
|
||||
val targetWidth = width.takeIf { it > 0 } ?: measuredWidth
|
||||
val targetHeight = height.takeIf { it > 0 } ?: measuredHeight
|
||||
val x = if (targetWidth > 0) targetWidth * xFraction.coerceIn(0f, 1f) else 0f
|
||||
val y = if (targetHeight > 0) targetHeight * yFraction.coerceIn(0f, 1f) else 0f
|
||||
triggerCloseTouchEvent(x, y, tapDurationMs)
|
||||
}
|
||||
|
||||
fun Activity.triggerRootCloseTouchEvent() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user