Multiple changes

This commit is contained in:
particle-box
2026-01-28 00:04:29 +05:30
parent 296769af49
commit a92f577aec
46 changed files with 21901 additions and 19932 deletions

View File

@@ -21,6 +21,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import me.eternal.purrfectsnap.bridge.BridgeService
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.Constants
@@ -45,12 +51,14 @@ import me.eternal.purrfectsnap.ui.manager.data.SnapchatAppInfo
import me.eternal.purrfectsnap.ui.overlay.RemoteOverlay
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.setup.SetupActivity
import me.eternal.purrfectsnap.task.AnnouncementCheckWorker
import java.io.ByteArrayInputStream
import java.lang.ref.WeakReference
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchConfiguration
import java.util.concurrent.TimeUnit
class RemoteSideContext(
@@ -123,6 +131,7 @@ class RemoteSideContext(
log.init()
log.verbose("Loading RemoteSideContext")
config.load()
ensureAutoUpdateCheckOnUpgrade()
launch {
mappings.apply {
init(androidContext)
@@ -132,6 +141,7 @@ class RemoteSideContext(
userLocale = config.locale
load()
}
scheduleAnnouncementCheck()
database.init()
streaksReminder.init()
scriptManager.init()
@@ -264,4 +274,42 @@ class RemoteSideContext(
intent.putExtra(EnumAction.ACTION_PARAMETER, action.key)
androidContext.startActivity(intent)
}
private fun scheduleAnnouncementCheck() {
val workManager = WorkManager.getInstance(androidContext)
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val inputData = Data.Builder()
.putString("announcements_url", "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt")
.putString("channel_name", "Announcements")
.putString("channel_description", "Notifications for PurrfectSnap announcements")
.putString("notification_title", "New announcement available")
.putString("notification_text", "Tap to open and read.")
.build()
val workRequest = PeriodicWorkRequestBuilder<AnnouncementCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()
workManager.enqueueUniquePeriodicWork(
"purrfectsnap_announcement_check",
ExistingPeriodicWorkPolicy.REPLACE,
workRequest
)
}
private fun ensureAutoUpdateCheckOnUpgrade() {
val currentVersion = BuildConfig.VERSION_CODE.toLong()
val lastVersion = sharedPreferences.getLong("last_build_version_code", -1L)
val reenabledOnce = sharedPreferences.getBoolean("auto_update_reenabled_once", false)
if (lastVersion == currentVersion) return
if (!reenabledOnce) {
config.root.global.updateSettings.autoUpdateCheck.set(true)
config.writeConfig()
sharedPreferences.edit()
.putBoolean("auto_update_reenabled_once", true)
.apply()
}
sharedPreferences.edit().putLong("last_build_version_code", currentVersion).apply()
}
}

View File

@@ -17,6 +17,9 @@ import me.eternal.purrfectsnap.download.FFMpegProcessor
import me.eternal.purrfectsnap.task.PendingTaskListener
import me.eternal.purrfectsnap.task.Task
import me.eternal.purrfectsnap.task.TaskType
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.absoluteValue
@@ -109,7 +112,11 @@ class CallDownloadSessionImpl(
return
}
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}_final.mp3")
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
val dateString = dateFormat.format(Date(callStartTimestamp))
val finalFileName = "Call_${author}_$dateString"
val outputFile = context.androidContext.cacheDir.resolve("${finalFileName}_final.mp3")
val pendingTask = context.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
@@ -136,24 +143,30 @@ class CallDownloadSessionImpl(
}
val sortedStreams = streams.filter { it.outputFile.exists() }.sortedBy { it.startTimestampMillis }
if (sortedStreams.isEmpty()) {
pendingTask.fail("No recorded audio data")
return@launch
}
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_AUDIO_STREAMS,
inputs = sortedStreams.map { it.outputFile.absolutePath },
output = outputFile,
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp) }
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp).coerceAtLeast(0L) }
)
)
DownloadProcessor(context, object: DownloadCallback.Default() {
override fun onSuccess(outputPath: String) {
context.log.verbose("Downloaded call $outputPath")
context.shortToast(context.translation["features.properties.downloader.properties.call_recorder.properties.call_recording_saved_toast"])
}
}).saveMediaToGallery(pendingTask, outputFile, DownloadMetadata(
mediaIdentifier = UUID.randomUUID().toString(),
outputPath = createNewFilePath(
context.config.root,
UUID.randomUUID().toString().hashCode().absoluteValue.toString(16),
finalFileName,
downloadSource = MediaDownloadSource.VOICE_CALL,
mediaAuthor = author,
creationTimestamp = System.currentTimeMillis()
@@ -162,6 +175,9 @@ class CallDownloadSessionImpl(
downloadSource = MediaDownloadSource.VOICE_CALL.translate(context.translation),
iconUrl = null
))
} catch (e: Exception) {
context.log.error("Failed to merge call recording", e)
pendingTask.fail("Merge failed: ${e.message}")
} finally {
streams.forEach { stream ->
stream.outputFile.delete()
@@ -172,4 +188,4 @@ class CallDownloadSessionImpl(
context.log.verbose("ending call")
}
}
}
}

View File

@@ -0,0 +1,105 @@
package me.eternal.purrfectsnap.task
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.MainActivity
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
class AnnouncementCheckWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return runCatching {
val url = inputData.getString("announcements_url") ?: return Result.failure()
val body = fetchBody(url)?.trim().orEmpty()
if (body.isEmpty()) return Result.success()
val hash = sha256(body)
val prefs = appContext.getSharedPreferences("prefs", 0)
val lastHash = prefs.getString("announcements_last_hash", null)
if (lastHash == null) {
prefs.edit().putString("announcements_last_hash", hash).apply()
return Result.success()
}
if (lastHash != hash) {
prefs.edit().putString("announcements_last_hash", hash).apply()
showAnnouncementNotification()
}
Result.success()
}.getOrElse {
Result.failure()
}
}
private fun fetchBody(url: String): String? {
val client = OkHttpClient()
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
return response.body?.string()
}
}
private fun sha256(text: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { byte -> "%02x".format(byte) }
}
private fun showAnnouncementNotification() {
val channelId = "purrfectsnap_announcements"
val name = inputData.getString("channel_name") ?: "Announcements"
val descriptionText = inputData.getString("channel_description") ?: "Notifications for PurrfectSnap announcements"
val title = inputData.getString("notification_title") ?: "New announcement available"
val text = inputData.getString("notification_text") ?: "Tap to open and read."
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("route", "home")
putExtra("show_announcements", true)
}
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
}
with(NotificationManagerCompat.from(appContext)) {
notify(2, builder.build())
}
}
}

View File

@@ -59,6 +59,8 @@ class UpdateCheckWorker(
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("show_changelog", true)
putExtra("changelog_version", versionName)
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)

View File

@@ -68,6 +68,8 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (::navController.isInitialized.not()) return
handleAnnouncementIntent(intent)
handleUpdateIntent(intent)
intent.getStringExtra("route")?.let { route ->
navController.popBackStack()
@@ -95,6 +97,8 @@ class MainActivity : ComponentActivity() {
activity = this@MainActivity
checkForRequirements()
}
handleAnnouncementIntent(intent)
handleUpdateIntent(intent)
val routes = Routes(managerContext)
routes.activityLauncher = ActivityLauncherHelper(this)
routes.getRoutes().forEach { it.init() }
@@ -256,4 +260,22 @@ class MainActivity : ComponentActivity() {
super.onDestroy()
unregisterReceiver(restartReceiver)
}
private fun handleAnnouncementIntent(intent: Intent) {
if (intent.getBooleanExtra("show_announcements", false)) {
applicationContext.getSharedPreferences("prefs", 0).edit()
.putBoolean("show_announcements_on_launch", true)
.apply()
}
}
private fun handleUpdateIntent(intent: Intent) {
if (intent.getBooleanExtra("show_changelog", false)) {
val version = intent.getStringExtra("changelog_version")
applicationContext.getSharedPreferences("prefs", 0).edit()
.putBoolean("show_changelog_on_launch", true)
.putString("changelog_version_on_launch", version)
.apply()
}
}
}

View File

@@ -936,6 +936,7 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) {
var searchValue by remember { mutableStateOf("") }
val isOverlay = remember { context.sharedPreferences.getBoolean("overlay_active", false) }
val scope = rememberCoroutineScope()
var currentSearchJob by remember { mutableStateOf<Job?>(null) }
val searchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
@@ -985,7 +986,9 @@ class FeaturesRootSection : Routes.Route() {
onValueChange = { keyword ->
searchValue = keyword
if (keyword.isEmpty()) {
navigateToMainRoot()
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
launchSearch(keyword, record = false, delayMs = 250L)
}
@@ -1019,7 +1022,9 @@ class FeaturesRootSection : Routes.Route() {
if (searchValue.isNotEmpty()) {
IconButton(onClick = {
searchValue = ""
navigateToMainRoot()
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
focusRequester.requestFocus()
}) {
Icon(
@@ -1140,6 +1145,7 @@ class FeaturesRootSection : Routes.Route() {
) {
var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) }
val focusRequester = remember { FocusRequester() }
val isOverlay = remember { context.sharedPreferences.getBoolean("overlay_active", false) }
var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
@@ -1364,8 +1370,14 @@ class FeaturesRootSection : Routes.Route() {
if (searchValue.text.isNotEmpty()) {
IconButton(onClick = {
searchValue = TextFieldValue("", TextRange(0))
navigateToMainRoot()
showSearchBar = false
updateSearch("", record = false)
if (isSearchResults) {
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
showSearchBar = false
}
}) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White)
}
@@ -1414,7 +1426,9 @@ class FeaturesRootSection : Routes.Route() {
IconButton(onClick = {
if (showSearchBar) {
searchValue = TextFieldValue("", TextRange(0))
navigateToMainRoot()
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
}
showSearchBar = !showSearchBar
}) {

View File

@@ -863,6 +863,30 @@ class HomeRootSection : Routes.Route() {
}
}
LaunchedEffect(Unit) {
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
context.sharedPreferences.edit()
.putBoolean("show_changelog_on_launch", false)
.remove("changelog_version_on_launch")
.apply()
version?.let {
showChangelogDialog = true
loadChangelog(it, changelogUrl)
}
}
}
LaunchedEffect(Unit) {
if (context.sharedPreferences.getBoolean("show_announcements_on_launch", false)) {
context.sharedPreferences.edit()
.putBoolean("show_announcements_on_launch", false)
.apply()
showAnnouncementsDialog = true
loadAnnouncements()
}
}
val onUpdateButtonClick: () -> Unit = {
latestUpdate?.let {
showChangelogDialog = true

View File

@@ -78,6 +78,7 @@ class RemoteOverlay(
fun close() {
if (!::dialog.isInitialized || !dialog.isShowing) return
dismissCallback = null
context.sharedPreferences.edit().putBoolean("overlay_active", false).apply()
context.androidContext.mainExecutor.execute {
dialog.dismiss()
}
@@ -92,6 +93,7 @@ class RemoteOverlay(
return
}
context.sharedPreferences.edit().putBoolean("overlay_active", true).apply()
context.androidContext.mainExecutor.execute {
dialog = object: Dialog(context.androidContext, R.style.FullscreenOverlayDialog) {
override fun dismiss() {
@@ -99,6 +101,9 @@ class RemoteOverlay(
if (it()) return
}
super.dismiss()
this@RemoteOverlay.context.sharedPreferences.edit()
.putBoolean("overlay_active", false)
.apply()
this@RemoteOverlay.context.config.writeConfig()
}
}

View File

@@ -38,6 +38,7 @@ 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 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
@@ -208,29 +209,40 @@ class AlertDialogs(
@Suppress("UNCHECKED_CAST")
fun UniqueSelectionDialog(property: PropertyPair<*>) {
val disabledKey = property.key.params.disabledKey
val noDisable = property.key.params.flags.contains(ConfigFlag.NO_DISABLE_KEY)
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
val disabledEntry = disabledKey ?: "null"
remove(disabledEntry)
add(0, disabledEntry)
if (disabledKey == null) {
if (noDisable) {
disabledKey?.let { remove(it) }
remove("null")
add(0, "null")
} else {
val disabledEntry = disabledKey ?: "null"
remove(disabledEntry)
add(0, disabledEntry)
if (disabledKey == null) {
remove("null")
add(0, "null")
}
}
}
var selectedValue by remember {
mutableStateOf(property.value.getNullable()?.toString() ?: (disabledKey ?: "null"))
val currentValue = property.value.getNullable()?.toString()
mutableStateOf(currentValue ?: if (noDisable) (keys.firstOrNull().orEmpty()) else (disabledKey ?: "null"))
}
DefaultDialogCard {
keys.forEachIndexed { index, item ->
fun select() {
selectedValue = item
if (disabledKey != null && item == disabledKey) {
if (!noDisable && disabledKey != null && item == disabledKey) {
property.value.setAny(disabledKey)
return
}
property.value.setAny(if (disabledKey == null && index == 0) null else item)
if (!noDisable && disabledKey == null && index == 0) {
property.value.setAny(null)
return
}
property.value.setAny(item)
}
Row(

View File

@@ -33,7 +33,7 @@ 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.2.0").get())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.2.5").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("252").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
rootProject.ext.set(

View File

@@ -697,6 +697,29 @@
"name": "تنزيل الملاحظات الصوتية تلقائيًا",
"description": "تنزيل الملاحظات الصوتية تلقائيًا عند تشغيلها"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "تنزيل صور الملف الشخصي",
"description": "يسمح لك بتنزيل صور الملف الشخصي من صفحة الملف الشخصي"
@@ -1646,6 +1669,17 @@
"name": "الكاميرا الافتراضية عند البدء",
"description": "تعيين الكاميرا الافتراضية عند فتح سناب شات"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "تسجيل HEVC",
"description": "يستخدم برنامج ترميز HEVC (H.265) لتسجيل الفيديو"
@@ -2184,6 +2218,17 @@
"back": "الكاميرا الخلفية",
"null": "تذكر آخر استخدام"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"call_recorder": {
"only_record_self": "تسجيل الذات فقط",
"only_record_others": "تسجيل الآخرين فقط",
@@ -3027,6 +3072,7 @@
"no_rules_found": "لم يتم العثور على قواعد",
"no_events": "لا توجد أحداث"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "بحث"
},

View File

@@ -815,6 +815,17 @@
"back": "পূর্ববর্তী ক্যামেরা",
"null": "সর্বশেষ ব্যবহৃত হয়েছে"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "ি FPS"
},
@@ -1324,6 +1335,29 @@
"name": "স্বয়ংক্রিয়ভাবে ডাউনলোড ভয়েস নোট",
"description": "স্বয়ংক্রিয়ভাবে ভয়েস বাজানোর সময় স্বয়ংক্রিয়রূপে সেগুলি ডাউনলোড করা হবে"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": " ি ",
"description": " ি ি "
@@ -2087,6 +2121,17 @@
"name": "প্রারম্ভিক ক্যামেরা",
"description": "খোলার সময় ডিফল্ট ক্যামেরা নির্ধারণ করা হয়"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "HVC ",
"description": "িি ি HVC (H.65) "
@@ -2939,6 +2984,7 @@
"no_rules_found": " ি ি",
"no_events": " "
},
"scopes_suffix": "scopes",
"search": {
"placeholder": ""
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -815,6 +815,17 @@
"back": "Back Camera",
"null": "Remember last used"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "Device default FPS"
},
@@ -1324,6 +1335,29 @@
"name": "Auto Download Voice Notes",
"description": "Automatically downloads voice notes when playing them"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "Download Profile Pictures",
"description": "Allows you to download Profile Pictures from the profile page"
@@ -2087,6 +2121,17 @@
"name": "Startup Default Camera",
"description": "Sets the default camera when opening Snapchat"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
@@ -2939,6 +2984,7 @@
"no_rules_found": "No rules found",
"no_events": "No events"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "Search"
},

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Select Language",
@@ -771,7 +771,26 @@
},
"call_recorder": {
"name": "Call Recorder",
"description": "Automatically records audio calls"
"description": "Manage call recording settings",
"properties": {
"call_recorder": {
"name": "Mode",
"description": "Select what should be recorded"
},
"auto_start_recording": {
"name": "Auto Start Recording",
"description": "Automatically start recording when a call starts"
},
"call_recorder_ui": {
"name": "Call Recorder UI",
"description": "Show the recording overlay UI during calls"
},
"call_recorder_ui_design": {
"name": "UI Design",
"description": "Select the design for the call recorder overlay"
},
"call_recording_saved_toast": "Saved"
}
},
"chat_wallpaper_downloader": {
"name": "Chat Wallpaper Downloader",
@@ -2156,6 +2175,12 @@
"only_record_others": "Only Record Others",
"record_both": "Record Both Sides"
},
"call_recorder_ui_design": {
"default": "Default",
"snapchat": "Snapchat",
"cyber": "Cyber",
"frost": "Frost"
},
"front_custom_frame_rate": {
"null": "Device default FPS"
},
@@ -3036,6 +3061,7 @@
"no_rules_found": "No rules found",
"no_events": "No events"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "Search"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Pilih Bahasa",
@@ -57,7 +57,7 @@
},
"sections": {
"home": {
"version_title": "v{versionName}· by Eternal",
"version_title": "v{versionName}· by Eternal",
"update_title": "Update PurrfectSnap",
"update_content": "Versi{version}tersedia!",
"update_button": "Unduh",
@@ -641,9 +641,9 @@
},
"features": {
"notices": {
"unstable": " Tidak stabil",
"ban_risk": " Fitur ini dapat menyebabkan bans",
"internal_behavior": " Ini mungkin melanggar perilaku internal Snapchat"
"unstable": "? Tidak stabil",
"ban_risk": "? Fitur ini dapat menyebabkan bans",
"internal_behavior": "? Ini mungkin melanggar perilaku internal Snapchat"
},
"options": {
"app_appearance": {
@@ -670,8 +670,8 @@
"stealth": "Mode Stealth",
"auto_reply": "Reply Otomatis",
"auto_delete_sent_messages": "Hapus Pesan Terkirim oleh Otomatis",
"mark_snaps_as_seen": " Mark Snaps seperti yang terlihat",
"mark_stories_as_seen_locally": " Mark Stories seperti yang terlihat secara lokal",
"mark_snaps_as_seen": "? Mark Snaps seperti yang terlihat",
"mark_stories_as_seen_locally": "? Mark Stories seperti yang terlihat secara lokal",
"conversation_info": "Info Percakapan:",
"e2e_encryption": "Gunakan Enkripsi E2E",
"message_logger": "Pencari Pesan:",
@@ -815,6 +815,17 @@
"back": "Kamera Belakang",
"null": "Ingat terakhir digunakan"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "FPS baku perangkat"
},
@@ -883,8 +894,8 @@
},
"spotlight_comments_username_icon": {
"user": "Ikon Nama Pengguna",
"👤": "Ikon Nama Pengguna",
"[👤]": "Ikon Nama Pengguna",
"??": "Ikon Nama Pengguna",
"[??]": "Ikon Nama Pengguna",
"default": "Ikon Nama Pengguna",
"no_icon": "Tak ada ikon"
},
@@ -1324,6 +1335,29 @@
"name": "Unduh Otomatis Catatan Suara",
"description": "Otomatis mengunduh catatan suara ketika memainkannya"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "Unduh Foto Profil",
"description": "Memungkinkan Anda untuk mengunduh Foto Profil dari halaman profil"
@@ -2087,6 +2121,17 @@
"name": "Awalan Kamera Baku",
"description": "Tata kamera bawaan ketika membuka Snapchat"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "HEVC Merekam",
"description": "Menggunakan HEVC (H.265) codec untuk rekaman video"
@@ -2939,6 +2984,7 @@
"no_rules_found": "Tidak ada aturan yang ditemukan",
"no_events": "Tidak ada peristiwa"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "Cari"
},

File diff suppressed because it is too large Load Diff

View File

@@ -815,6 +815,17 @@
"back": "バックカメラ",
"null": "最後の使用を記憶して下さい"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": " FPS"
},
@@ -1324,6 +1335,29 @@
"name": "自動ダウンロード 音声メモ",
"description": "再生時に音声メモを自動的にダウンロード"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "",
"description": ""
@@ -2087,6 +2121,17 @@
"name": "",
"description": "Snapchat"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": " ",
"description": "HEVC(H.265)使"
@@ -2939,6 +2984,7 @@
"no_rules_found": "",
"no_events": ""
},
"scopes_suffix": "scopes",
"search": {
"placeholder": ""
},

View File

@@ -815,6 +815,17 @@
"back": "뒤 사진기",
"null": "자주 묻는 질문"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": " FPS"
},
@@ -1324,6 +1335,29 @@
"name": "자동차 다운로드 음성 메모",
"description": "자동으로 음성 메모를 다운로드 할 때"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "Profile Pictures ",
"description": " "
@@ -2087,6 +2121,17 @@
"name": " ",
"description": "Snapchat "
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": " ",
"description": "HEVC (H.265) "
@@ -2939,6 +2984,7 @@
"no_rules_found": " ",
"no_events": ""
},
"scopes_suffix": "scopes",
"search": {
"placeholder": ""
},

View File

@@ -769,6 +769,29 @@
"name": "دابەزاندنی ئۆتۆماتیکی دەنگەکان",
"description": "خۆکارانە نامە دەنگییەکان دادەبەزێنێت لەکاتی لێدانیان"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "دابەزاندنی وێنەی پڕۆفایل",
"description": "ڕێگەت دەدات وێنەی پڕۆفایلەکان دابەزێنیت لە پەڕەی پڕۆفایلەوە"
@@ -1690,6 +1713,17 @@
"name": "کامێرای بنەڕەتی کاتی کردنەوە",
"description": "کامێرای بنەڕەتی دیاری دەکات کاتێک سناپچات دەکرێتەوە"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "تۆمارکردنی HEVC",
"description": "کۆدێکی HEVC (H.265) بەکاردەهێنێت بۆ تۆمارکردنی ڤیدیۆ"
@@ -2147,6 +2181,17 @@
"back": "کامێرای دواوە",
"null": "دواهەمین بەکارهێنراو بیربکەرەوە"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "FPS-ی بنەڕەتی ئامێر"
},
@@ -3022,6 +3067,7 @@
"no_rules_found": "هیچ یاسایەک نەدۆزرایەوە",
"no_events": "هیچ ڕووداوێک نییە"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "گەڕان"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Taal selecteren",
@@ -11,7 +11,7 @@
"generate_failure": "Er is een fout opgetreden bij het genereren van mappen, probeer het opnieuw."
},
"permissions": {
"dialog": "Voltooien van deze essentiële om te blijven:",
"dialog": "Voltooien van deze essentiële om te blijven:",
"notification_access": "Toegang tot kennisgeving",
"battery_optimization": "Batterijoptimalisatie",
"display_over_other_apps": "Over andere apps weergeven",
@@ -57,7 +57,7 @@
},
"sections": {
"home": {
"version_title": "v{versionName}· door Eternal",
"version_title": "v{versionName}· door Eternal",
"update_title": "PurrfectSnap-update",
"update_content": "Versie{version}is beschikbaar!",
"update_button": "Downloaden",
@@ -154,12 +154,12 @@
},
"manage_rule_feature": {
"disable_state_option": "Uitgeschakeld",
"disable_state_subtext": "Geen vrienden/groepen worden beïnvloed",
"disable_state_subtext": "Geen vrienden/groepen worden beïnvloed",
"whitelist_state_option": "Niemand behalve...",
"whitelist_state_subtext": "Alleen{count}vrienden/groepen worden beïnvloed door deze regel",
"whitelist_state_subtext": "Alleen{count}vrienden/groepen worden beïnvloed door deze regel",
"whitelist_state_button": "Selecteer toegestane vrienden/groepen",
"blacklist_state_option": "Iedereen behalve...",
"blacklist_state_subtext": "Iedereen behalve{count}vrienden/groepen worden beïnvloed door deze regel",
"blacklist_state_subtext": "Iedereen behalve{count}vrienden/groepen worden beïnvloed door deze regel",
"blacklist_state_button": "Uitgesloten vrienden/groepen selecteren",
"clear_list_button": "Lijst vrienden/groepen wissen",
"dialog_clear_confirmation_text": "Weet je zeker dat je de lijst wilt wissen?"
@@ -181,7 +181,7 @@
"export_base64_button": "Basis exporteren64",
"import_base64_button": "Basis importeren64",
"invalid_key_size_32_bytes": "Ongeldige sleutelgrootte. Geef een 32-byte sleutel.",
"successfully_imported_key": "Sleutel succesvol geïmporteerd.",
"successfully_imported_key": "Sleutel succesvol geïmporteerd.",
"failed_to_import_key": "Kon sleutel niet importeren:{message}",
"rules_title": "Regels",
"participants_text": "{count}deelnemers",
@@ -235,27 +235,27 @@
"import_file_button": "Bestand importeren",
"file_not_found": "Bestand niet gevonden",
"file_import_failed": "Kon bestand niet importeren:{error}",
"file_imported": "Bestand geïmporteerd met succes",
"file_imported": "Bestand geïmporteerd met succes",
"file_delete_failed": "Verwijderen van bestand is mislukt",
"no_files_hint": "Hier kunt u bestanden importeren voor gebruik in Snapchat. Druk op onderstaande knop om een bestand te importeren."
},
"better_location": {
"spoofed_coordinates_title": "Lat{latitude}, Lng{longitude}",
"save_coordinates_dialog_title": "Coördinaten opslaan",
"save_coordinates_dialog_title": "Coördinaten opslaan",
"saved_name_dialog_hint": "Opgeslagen naam",
"latitude_dialog_hint": "Breedtegraad",
"longitude_dialog_hint": "Lengtegraad",
"save_dialog_button": "Opslaan",
"choose_location_button": "Kies een locatie",
"manual_coordinates_hint": "Stel de coördinaten handmatig in.",
"manual_coordinates_hint": "Stel de coördinaten handmatig in.",
"saved_coordinates_subtitle": "Uw opgeslagen spooflocaties beheren",
"teleport_to_friend_button": "Teleporteer naar vriend",
"spoof_location_toggle": "Spoof-locatie",
"suspend_location_updates": "Locatie-updates onderbreken",
"saved_coordinates_title": "Opgeslagen coördinaten",
"no_saved_coordinates_hint": "Geen opgeslagen coördinaten",
"delete_dialog_title": "Opgeslagen coördinaat verwijderen",
"delete_dialog_message": "Weet u zeker dat u deze opgeslagen coördinaat wilt verwijderen?",
"saved_coordinates_title": "Opgeslagen coördinaten",
"no_saved_coordinates_hint": "Geen opgeslagen coördinaten",
"delete_dialog_title": "Opgeslagen coördinaat verwijderen",
"delete_dialog_message": "Weet u zeker dat u deze opgeslagen coördinaat wilt verwijderen?",
"teleport_to_friend_title": "Teleporteer naar vriend",
"search_bar": "Zoeken",
"no_friends_map": "Geen vrienden op de kaart",
@@ -290,15 +290,15 @@
},
"export_config": {
"title": "Gevoelige gegevens exporteren?",
"content": "Wilt u de configuratie met gevoelige gegevens exporteren? (Zoals locatiecoördinaten, enz.)"
"content": "Wilt u de configuratie met gevoelige gegevens exporteren? (Zoals locatiecoördinaten, enz.)"
},
"messaging_action": {
"title": "Kies inhoudstypen om te verwerken",
"select_all_button": "Alles selecteren"
},
"file_imports": {
"no_files_settings_hint": "Geen bestanden gevonden. Zorg ervoor dat u de benodigde bestanden hebt geïmporteerd in de sectie Bestand Importeren",
"settings_select_file_hint": "Een geïmporteerd bestand selecteren"
"no_files_settings_hint": "Geen bestanden gevonden. Zorg ervoor dat u de benodigde bestanden hebt geïmporteerd in de sectie Bestand Importeren",
"settings_select_file_hint": "Een geïmporteerd bestand selecteren"
}
},
"scripting": {
@@ -318,7 +318,7 @@
"import_from_url_button": "Importeren uit URL",
"import_script_from_url_title": "Script importeren van URL",
"import_script_warning": "Installeer alleen scripts van bronnen die je vertrouwt.",
"installed_scripts_tab": "Geïnstalleerd",
"installed_scripts_tab": "Geïnstalleerd",
"manage_repos_button": "Repo's beheren",
"module_data_cleared": "Module gegevens gewist!",
"module_not_found": "Module niet gevonden",
@@ -328,7 +328,7 @@
"no_settings_for_module": "Deze module heeft geen instellingen",
"open_module_failed": "Openen van modulebestand is mislukt",
"open_scripts_folder_button": "Map scripts openen",
"script_already_installed": "Script is al geïnstalleerd",
"script_already_installed": "Script is al geïnstalleerd",
"select_folder_button": "Map kiezen",
"select_scripts_folder_toast": "Selecteer eerst een scriptmap",
"update_module_button": "Module bijwerken",
@@ -339,12 +339,12 @@
"no_repos_added": "Geen repositories toegevoegd",
"repo_list_info": "Zoek repositories hier:",
"link_text": "Repositorylijst",
"script_already_installed": "Script is al geïnstalleerd",
"script_already_installed": "Script is al geïnstalleerd",
"script_downloaded": "Gedownload script",
"could_not_create_file": "Kon bestand niet aanmaken",
"no_scripts_folder_selected": "Selecteer eerst een scriptmap",
"no_scripts_available": "Geen scripts beschikbaar",
"installed_button": "Geïnstalleerd",
"installed_button": "Geïnstalleerd",
"download_button": "Downloaden"
},
"repos": {
@@ -437,7 +437,7 @@
"save_button": "Opslaan",
"back_button_description": "Ga terug",
"expand_button_description": "Categorie uitvouwen of instorten",
"exported_toast": "Trackerconfiguratie geëxporteerd",
"exported_toast": "Trackerconfiguratie geëxporteerd",
"export_failed_toast": "Exporteren van tracker is mislukt:{message}"
},
"friend_tracker_import": {
@@ -445,7 +445,7 @@
"confirm_button": "Importeren",
"back_button_description": "Ga terug",
"expand_button_description": "Categorie uitvouwen of instorten",
"imported_toast": "Tracker geïmporteerd",
"imported_toast": "Tracker geïmporteerd",
"import_failed_toast": "Importeren van tracker is mislukt:{message}"
},
"friend_tracker_catalog": {
@@ -666,7 +666,7 @@
"auto_download": "Automatisch downloaden",
"auto_save": "Berichten automatisch opslaan",
"unsaveable_messages": "Wat? Niet op te slaan berichten",
"auto_open_snaps": "📷 Auto Open Snaps",
"auto_open_snaps": "?? Auto Open Snaps",
"stealth": "Stealth-modus",
"auto_reply": "Automatisch reageren",
"auto_delete_sent_messages": "Verzonden berichten automatisch verwijderen",
@@ -815,6 +815,17 @@
"back": "Achterste camera",
"null": "Herinner laatst gebruikt"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "Apparaat standaard FPS"
},
@@ -883,8 +894,8 @@
},
"spotlight_comments_username_icon": {
"user": "Gebruikersnaam pictogram",
"👤": "Gebruikersnaam pictogram",
"[👤]": "Gebruikersnaam pictogram",
"??": "Gebruikersnaam pictogram",
"[??]": "Gebruikersnaam pictogram",
"default": "Gebruikersnaam pictogram",
"no_icon": "Geen pictogram"
},
@@ -985,7 +996,7 @@
},
"double_tap_chat_action": {
"like_message": "Net als bericht",
"copy_text": "Tekst naar klembord kopiëren",
"copy_text": "Tekst naar klembord kopiëren",
"delete_message": "Bericht verwijderen",
"mark_as_read": "Markeren als gelezen",
"custom_emoji_reaction": "Aangepaste Emoji reactie",
@@ -1139,8 +1150,8 @@
"description": "Spoofs uw locatie naar een opgegeven"
},
"coordinates": {
"name": "Coördinaten",
"description": "De coördinaten van de spoofed-locatie instellen"
"name": "Coördinaten",
"description": "De coördinaten van de spoofed-locatie instellen"
},
"walk_radius": {
"name": "Loop Straal",
@@ -1310,7 +1321,7 @@
},
"merge_overlays": {
"name": "Overlays samenvoegen",
"description": "Combineert de tekst en de media van een snap in één bestand"
"description": "Combineert de tekst en de media van een snap in één bestand"
},
"force_image_format": {
"name": "Afbeeldingsformaat forceren",
@@ -1324,6 +1335,29 @@
"name": "Automatisch downloaden Stemnoten",
"description": "Automatisch voice notes downloaden bij het afspelen ervan"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "Profielfoto's downloaden",
"description": "Hiermee kunt u profielfoto's downloaden van de profielpagina"
@@ -1594,7 +1628,7 @@
"properties": {
"group_notifications": {
"name": "Groepsmeldingen",
"description": "Groepsmeldingen in één enkele"
"description": "Groepsmeldingen in één enkele"
},
"chat_preview": {
"name": "Chatvoorbeeld",
@@ -1610,7 +1644,7 @@
},
"stacked_media_messages": {
"name": "Gestapelde mediaberichten",
"description": "Combineert meerdere mediaberichten in één tekstmelding wanneer ze niet kunnen worden bekeken. Gebruik in combinatie met Chat Preview"
"description": "Combineert meerdere mediaberichten in één tekstmelding wanneer ze niet kunnen worden bekeken. Gebruik in combinatie met Chat Preview"
},
"friend_add_source": {
"name": "Vriend Bron toevoegen",
@@ -2053,7 +2087,7 @@
},
"black_photos": {
"name": "Zwarte foto's",
"description": "Vervangt gevangen foto's door een zwarte achtergrond\nVideo's worden niet beïnvloed"
"description": "Vervangt gevangen foto's door een zwarte achtergrond\nVideo's worden niet beïnvloed"
},
"immersive_camera_preview": {
"name": "Onderdompelend voorbeeld",
@@ -2087,6 +2121,17 @@
"name": "Standaardcamera opstarten",
"description": "Stelt de standaard camera in bij het openen van Snapchat"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "HEVC Registratie",
"description": "Gebruikt HEVC (H.265) codec voor video-opname"
@@ -2103,11 +2148,11 @@
},
"remaining_hours": {
"name": "Resterende tijd",
"description": "De resterende tijd vóór de kennisgeving (uren)"
"description": "De resterende tijd vóór de kennisgeving (uren)"
},
"group_notifications": {
"name": "Groepsmeldingen",
"description": "Groepsmeldingen in één enkele"
"description": "Groepsmeldingen in één enkele"
}
}
},
@@ -2287,7 +2332,7 @@
},
"best_friend_pinning": {
"name": "Beste vriend Pinning",
"description": "Hiermee kun je een vriend als je nummer één beste vriend prikken. Opmerking: Alleen jij kunt je gepinde beste vriend zien"
"description": "Hiermee kun je een vriend als je nummer één beste vriend prikken. Opmerking: Alleen jij kunt je gepinde beste vriend zien"
},
"e2ee": {
"name": "End-to-end-versleuteling",
@@ -2342,7 +2387,7 @@
"description": "Automatisch herlaadt scripts wanneer ze veranderen"
},
"integrated_ui": {
"name": "Geïntegreerde UI",
"name": "Geïntegreerde UI",
"description": "Hiermee kunnen scripts aangepaste UI-componenten toevoegen aan Snapchat"
},
"disable_log_anonymization": {
@@ -2460,7 +2505,7 @@
"SNAP": "Knap",
"SAVEABLE_SNAP": "Opslaan",
"null": "Standaard Snapchat",
"multiple_media_toast": "U kunt slechts één media per keer verzenden"
"multiple_media_toast": "U kunt slechts één media per keer verzenden"
},
"mark_as_seen": {
"no_unseen_snaps_toast": "Geen ongeziene snaps gevonden!",
@@ -2749,7 +2794,7 @@
"queue_cleared": "Wachtrij goedgekeurd en statistieken opnieuw ingesteld",
"queue_cleared_title": "Wachtrij is leeg",
"queue_cleared_reset": "Wachtrij leeggemaakt & herstellen",
"queue_cleared_feedback": "Klaar{count}klikken in de wachtrij • Terugzetten{processed}verwerkt aantal",
"queue_cleared_feedback": "Klaar{count}klikken in de wachtrij Terugzetten{processed}verwerkt aantal",
"queue_cleared_feedback_simple": "Reset{processed}verwerkt aantal",
"unknown_sender": "Onbekend",
"unknown_user": "Onbekende gebruiker",
@@ -2780,7 +2825,7 @@
"export_logs_failure": "Exporteren van logs is mislukt. Check logcat voor meer informatie.",
"deleted_logs_count": "Verwijderd{count}logs"
},
"script_imported": "Script{name}geïmporteerd!",
"script_imported": "Script{name}geïmporteerd!",
"script_import_failed": "Kon script niet importeren.{error}. Controleer logs voor meer details",
"script_updating": "Bijwerken van script{name}...",
"script_updated": "Bijgewerkt{name}naar versie{version}",
@@ -2800,7 +2845,7 @@
"script_no_scripts_found": "Geen scripts gevonden",
"script_ok_timeout": "OK{timeout}",
"scripting_tagline": "Beheer scripts, import en mappen",
"installed_scripts_tab": "Geïnstalleerd",
"installed_scripts_tab": "Geïnstalleerd",
"catalog_tab": "Catalogus",
"no_scripts_folder_selected_title": "Selecteer uw scriptmap om aan de slag te gaan",
"select_folder_button": "Map kiezen",
@@ -2913,7 +2958,7 @@
"hu": "Hongaars",
"ro": "Roemeens",
"bg": "Bulgaars",
"hr": "Kroatië",
"hr": "Kroatië",
"sk": "Slowaaks",
"sl": "Sloveens",
"et": "Ests",
@@ -2939,6 +2984,7 @@
"no_rules_found": "Geen regels gevonden",
"no_events": "Geen gebeurtenissen"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "Zoeken"
},
@@ -3114,7 +3160,7 @@
"import_from_url": "Importeren uit URL",
"open_scripts_folder": "Scriptsmap openen",
"import_script_from_url": "Script importeren van URL",
"warning_imported_scripts": "Waarschuwing: geïmporteerde scripts kunnen schadelijk zijn voor uw apparaat. Alleen scripts importeren uit vertrouwde bronnen.",
"warning_imported_scripts": "Waarschuwing: geïmporteerde scripts kunnen schadelijk zijn voor uw apparaat. Alleen scripts importeren uit vertrouwde bronnen.",
"enter_url_here": "Voer hier URL in:",
"import": "Importeren",
"cancel": "Annuleren",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -815,6 +815,17 @@
"back": "Задня камера",
"null": "Пам'яті останнього"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": "Тип пристрою FPS"
},
@@ -1324,6 +1335,29 @@
"name": "Автозавантаження Голосові ноти",
"description": "Автоматично завантажує голосові ноти при грі їх"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "Завантажити профіль Фотографії",
"description": "Дозволяє завантажувати фотографії профілю з сторінки профілю"
@@ -2087,6 +2121,17 @@
"name": "За замовчуванням камера",
"description": "Налаштування камери за замовчуванням при відкритті Snapchat"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": "ГЕВЦ Запис",
"description": "Використання HEVC (H.265) кодека для запису відео"
@@ -2939,6 +2984,7 @@
"no_rules_found": "Не знайдено правила",
"no_events": "Немає подій"
},
"scopes_suffix": "scopes",
"search": {
"placeholder": "Пошук"
},

View File

@@ -815,6 +815,17 @@
"back": "后镜头",
"null": "记住上次使用"
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"front_custom_frame_rate": {
"null": " FPS"
},
@@ -1324,6 +1335,29 @@
"name": "自动下载 语音符",
"description": "播放时自动下载语音注释"
},
\"call_recorder\": {
\"name\": \"Call Recorder\",
\"description\": \"Manage call recording settings\",
\"properties\": {
\"call_recorder\": {
\"name\": \"Mode\",
\"description\": \"Select what should be recorded\"
},
\"auto_start_recording\": {
\"name\": \"Auto Start Recording\",
\"description\": \"Automatically start recording when a call starts\"
},
\"call_recorder_ui\": {
\"name\": \"Call Recorder UI\",
\"description\": \"Show the recording overlay UI during calls\"
},
\"call_recorder_ui_design\": {
\"name\": \"UI Design\",
\"description\": \"Select the design for the call recorder overlay\"
},
\"call_recording_saved_toast\": \"Saved\"
}
},
"download_profile_pictures": {
"name": "",
"description": ""
@@ -2087,6 +2121,17 @@
"name": "",
"description": " Snapchat "
},
\"call_recorder\": {
\"only_record_self\": \"Only Record Self\",
\"only_record_others\": \"Only Record Others\",
\"record_both\": \"Record Both Sides\"
},
\"call_recorder_ui_design\": {
\"default\": \"Default\",
\"snapchat\": \"Snapchat\",
\"cyber\": \"Cyber\",
\"frost\": \"Frost\"
},
"hevc_recording": {
"name": " ",
"description": "使HEVC(H.265)"
@@ -2939,6 +2984,7 @@
"no_rules_found": "",
"no_events": ""
},
"scopes_suffix": "scopes",
"search": {
"placeholder": ""
},

View File

@@ -50,6 +50,15 @@ class DownloaderConfig : ConfigContainer() {
set(mutableListOf("success", "progress", "failure"))
}
val customPathFormat = string("custom_path_format") { addNotices(FeatureNotice.UNSTABLE) }
val callRecorder = unique("call_recorder", "only_record_self", "only_record_others", "record_both") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val fileHashCheck = boolean("file_hash_check")
inner class CallRecorderOptions : ConfigContainer() {
val callRecorder = unique("call_recorder", "only_record_self", "only_record_others", "record_both") { addNotices(FeatureNotice.UNSTABLE) }
val autoStartRecording = boolean("auto_start_recording", false)
val callRecorderUi = boolean("call_recorder_ui", true)
val callRecorderUiDesign = unique("call_recorder_ui_design", "default", "snapchat", "cyber", "frost") {
addFlags(ConfigFlag.NO_DISABLE_KEY)
}.apply { set("default") }
}
val callRecorder = container("call_recorder", CallRecorderOptions()) { requireRestart() }
val chatWallpaperDownloader = boolean("chat_wallpaper_downloader") { requireRestart() }
}

View File

@@ -59,7 +59,7 @@ class Global : ConfigContainer() {
val disableSnapSplitting = boolean("disable_snap_splitting") { addNotices(FeatureNotice.UNSTABLE) }
inner class UpdateSettings : ConfigContainer() {
val autoUpdateCheck = boolean("auto_update_check")
val autoUpdateCheck = boolean("auto_update_check", true)
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
val updateChannel = unique("update_channel", "stable", "prerelease")
}

View File

@@ -5,173 +5,279 @@ import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.core.ui.InAppOverlay
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
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.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
class CallRecorder : Feature("Call Recorder") {
private var wasInCall = false
private var callDownloadSession: CallDownloadSession? = null
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
inner class LazyStream(
private val uiState get() = context.inAppOverlay.callRecorderState
private val callRecorderConfig get() = context.config.downloader.callRecorder
inner class CallStreamWrapper(
private val audioFormat: AudioFormat,
private val startTimestamp: Long = System.currentTimeMillis(),
) {
private var stream: ParcelFileDescriptor.AutoCloseOutputStream? = null
private var stream: OutputStream? = null
fun get(): OutputStream? {
if (stream != null) return stream
if (callDownloadSession == null) return null
fun write(buffer: ByteArray) {
if (!uiState.isRecording || callDownloadSession == null) return
if (stream == null) {
runCatching {
stream = ParcelFileDescriptor.AutoCloseOutputStream(
callDownloadSession?.createStream(
System.currentTimeMillis(),
audioFormat.channelCount,
audioFormat.sampleRate,
audioFormat.encoding
) ?: return
)
}
}
runCatching { stream?.write(buffer) }
}
stream = ParcelFileDescriptor.AutoCloseOutputStream(
callDownloadSession?.createStream(
startTimestamp,
audioFormat.channelCount,
audioFormat.sampleRate,
audioFormat.encoding
) ?: return null
)
return stream
fun close() {
runCatching { stream?.close() }
stream = null
}
}
private fun initCallDownloadSession(conversationId: String) {
private fun finalizeSession() {
val session = callDownloadSession ?: return
context.log.verbose("Finalizing call recording session")
runCatching { session.end() }
callDownloadSession = null
streams.values.forEach { it.close() }
}
private fun startManualRecording() {
if (!uiState.isRecording) {
uiState.isRecording = true
uiState.recordingStartTime = System.currentTimeMillis()
// Initialize call download session if not already started
if (callDownloadSession == null) {
context.log.verbose("Starting call recorder session: ${uiState.currentAuthor}")
callDownloadSession = context.bridgeClient.startCallDownload(System.currentTimeMillis(), uiState.currentAuthor)
}
ensureSessionStarted()
}
}
private fun stopRecording() {
if (uiState.isRecording) {
uiState.isRecording = false
finalizeSession()
}
}
private fun onCallStarted(conversationId: String) {
if (wasInCall) return
wasInCall = true
val author = (if (context.database.getConversationType(conversationId) == 1) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
} else {
context.database.getDMOtherParticipant(conversationId)?.let { context.database.getFriendInfo(it)?.mutableUsername }
}) ?: "unknown"
callDownloadSession = context.bridgeClient.startCallDownload(System.currentTimeMillis(), author)
context.log.verbose("Call started for: $author")
uiState.currentAuthor = author
if (callRecorderConfig.callRecorderUi.get()) {
uiState.offsetX = 0f
uiState.offsetY = 0f
uiState.isMinimized = false
uiState.lastInteractionTime = System.currentTimeMillis()
uiState.showOverlay = true
}
if (callRecorderConfig.autoStartRecording.get()) {
startManualRecording()
}
}
private fun onCallStarted(conversationId: String) {
initCallDownloadSession(conversationId)
private fun onCallEnded() {
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
wasInCall = false
finalizeSession()
streams.clear()
// Hide overlay UI (don't reset offsets here to avoid jumping during animation)
uiState.isRecording = false
uiState.showOverlay = false
}
private fun onCallEnded(conversationId: String) {
callDownloadSession?.end()
}
override fun init() {
val callRecorderConfig = context.config.downloader.callRecorder.getNullable()
if (callRecorderConfig == null) return
val streams = ConcurrentHashMap<Int, LazyStream>() // audioTrack -> stream
runCatching {
findClass("com.snapchat.talkcorev3.CallingSessionState")
}.getOrNull()?.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val callingState = instance.getObjectFieldOrNull("mLocalUser")?.getObjectField("mCallingState")
if (callingState.toString() == "IN_CALL") {
// TODO: implement for older Snapchat versions
}
} ?: findClass("com.snapchat.talkcorev3.TSCallingStateUpdateParams").hookConstructor(
HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationId = SnapUUID(instance.getObjectField("mConversationId")).toString()
if (instance.getObjectFieldOrNull("mInCall") == true) {
if (!wasInCall) {
wasInCall = true
onCallStarted(conversationId)
}
} else {
if (wasInCall) {
wasInCall = false
onCallEnded(conversationId)
private fun detectCallState() {
// Primary hook: TalkCore native state updates
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.arg<Any>(0)
val conversationId = params.getObjectFieldOrNull("mConversationId")?.toString() ?: return@hook
val inCall = params.getObjectFieldOrNull("mInCall") as? Boolean ?: false
context.log.verbose("updateTSCallingSession: inCall=$inCall convo=$conversationId", "CallRecorder")
if (inCall) onCallStarted(conversationId) else onCallEnded()
}
hook("disposeTSCallingSession", HookStage.BEFORE) {
context.log.verbose("disposeTSCallingSession triggered", "CallRecorder")
onCallEnded()
}
}
}
}
// Legacy/Generic hook: TSCallingStateUpdateParams constructor
runCatching {
findClass("com.snapchat.talkcorev3.TSCallingStateUpdateParams").hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationId = instance.getObjectFieldOrNull("mConversationId")?.toString() ?: return@hookConstructor
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: false
if (inCall) onCallStarted(conversationId) else onCallEnded()
}
}
}
private fun checkStreamsAndCleanup() {
// If all audio streams are released, the call is likely over
if (streams.isEmpty() && wasInCall) {
context.coroutineScope.launch {
delay(200)
if (streams.isEmpty() && wasInCall) {
context.log.verbose("Call end detected via stream release", "CallRecorder")
onCallEnded()
}
}
}
}
private fun ensureSessionStarted() {
if (callDownloadSession != null) return
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
?: context.feature(Messaging::class).lastFocusedConversationId
?: "unknown"
onCallStarted(conversationId)
}
override fun init() {
if (callRecorderConfig.callRecorder.getNullable() == null) return
// Listen for UI control events
context.event.subscribe(InAppOverlay.CallRecorderControlEvent::class) { event ->
if (event.start) startManualRecording() else stopRecording()
}
detectCallState()
val recorderConfig = callRecorderConfig.callRecorder.get()
AudioRecord::class.java.apply {
if (callRecorderConfig == "only_record_others") return@apply
declaredConstructors.first { it.parameterCount > 5 }.hook(HookStage.AFTER) { param ->
val audioAttributes = param.arg<AudioAttributes>(0)
context.log.verbose(audioAttributes.usage)
if (audioAttributes.usage != AudioAttributes.USAGE_UNKNOWN) return@hook
val audioFormat = param.arg<AudioFormat>(1)
val hashCode = param.thisObject<Any>().hashCode()
streams.put(hashCode, LazyStream(audioFormat))
context.log.verbose("AudioRecord called usage=${audioAttributes.usage}, format=$audioFormat")
if (recorderConfig == "only_record_others") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val isCall = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
attributes?.usage == AudioAttributes.USAGE_UNKNOWN ||
runCatching { param.arg<Int>(0) }.getOrNull() == 7 // 7 = VOICE_COMMUNICATION
if (isCall) {
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)
ensureSessionStarted()
}
}
getMethod("read", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(
HookStage.AFTER) { param ->
val readBytes = param.getResult() as Int
if (readBytes <= 0) return@hook
streams[param.thisObject<Any>().hashCode()]?.let { handlers ->
val byteBuffer = param.arg<ByteBuffer>(0)
val position = byteBuffer.position()
val buffer = ByteArray(readBytes)
byteBuffer.get(buffer)
byteBuffer.position(position)
runCatching {
handlers.get()?.write(buffer, 0, buffer.size)
}.onFailure {
context.log.error("Failed to record call audio data", it)
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 buffer = when (val data = param.arg<Any>(0)) {
is ByteBuffer -> ByteArray(result).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + result)
is ShortArray -> ByteArray(result * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, result)
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("release", HookStage.BEFORE) {
runCatching {
streams.remove(it.thisObject<Any>().hashCode())?.get()?.close()
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("release", HookStage.BEFORE) {
streams.remove(it.thisObject<Any>().hashCode())?.close()
checkStreamsAndCleanup()
}
}
AudioTrack::class.java.apply {
if (callRecorderConfig == "only_record_self") return@apply
getConstructor(
AudioAttributes::class.java,
AudioFormat::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
).hook(HookStage.AFTER) { param ->
val audioAttributes = param.arg<AudioAttributes>(0)
if (audioAttributes.usage != AudioAttributes.USAGE_VOICE_COMMUNICATION) return@hook
val audioFormat = param.arg<AudioFormat>(1)
val hashCode = param.thisObject<Any>().hashCode()
streams.put(hashCode, LazyStream(audioFormat))
context.log.verbose("AudioTrack called usage=${audioAttributes.usage}, format=$audioFormat")
}
getMethod("write", ByteBuffer::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType).hook(
HookStage.BEFORE) { param ->
streams[param.thisObject<Any>().hashCode()]?.let { handlers ->
val byteBuffer = param.arg<ByteBuffer>(0)
val position = byteBuffer.position()
val buffer = ByteArray(param.arg(1))
byteBuffer.get(buffer)
byteBuffer.position(position)
runCatching {
handlers.get()?.write(buffer, 0, buffer.size)
}.onFailure {
context.log.error("Failed to record call audio data", it)
}
if (recorderConfig == "only_record_self") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val isCall = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
attributes?.usage == AudioAttributes.USAGE_UNKNOWN ||
runCatching { param.arg<Int>(0) }.getOrNull() in listOf(0, 7) // 0 = CALL, 7 = SCO
if (isCall) {
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)
ensureSessionStarted()
}
}
hook("release", HookStage.BEFORE) {
runCatching { streams.remove(it.thisObject<Any>().hashCode())?.get()?.close() }
hook("write", HookStage.BEFORE) { param ->
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val data = param.arg<Any>(0)
val size = if (param.args().size > 2) param.arg(2) else if (data is ByteArray) data.size else if (data is ShortArray) data.size else if (data is ByteBuffer) data.remaining() else 0
if (size <= 0) return@hook
val buffer = when (data) {
is ByteBuffer -> ByteArray(size).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + size)
is ShortArray -> ByteArray(size * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, size)
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("release", HookStage.BEFORE) {
streams.remove(it.thisObject<Any>().hashCode())?.close()
checkStreamsAndCleanup()
}
}
}

View File

@@ -1,11 +1,24 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.view.ViewGroup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -18,6 +31,7 @@ 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.ui.getValdiContext
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -65,40 +79,76 @@ class ChatWallpaperDownloader : Feature("Chat Wallpaper Downloader") {
val chatWallpaper = chatWallpapers[conversationId] ?: return@post
event.parent.addView(createComposeView(event.parent.context) {
Button(
val label = context.translation["chat_wallpaper_downloader.download_button"]
val stroke = Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.75f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.6f)
)
)
val background = Brush.linearGradient(
listOf(
Color(0xFF2A2452),
Color(0xFF1B163A)
)
)
val shape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
onClick = {
val friendInfo = runCatching {
context.database.getDMOtherParticipant(conversationId)?.let {
context.database.getFriendInfo(it)
} ?: context.database.getFriendInfo(context.database.myUserId)
}.getOrNull()
context.feature(MediaDownloader::class).provideDownloadManagerClient(
mediaIdentifier = chatWallpaper.contentObject.contentHashCode().absoluteValue.toString(16),
mediaAuthor = friendInfo?.mutableUsername ?: "unknown",
downloadSource = MediaDownloadSource.CHAT_WALLPAPER,
friendInfo = friendInfo,
).downloadInputMedias(
arrayOf(
InputMedia(
content = Base64.UrlSafe.encode(chatWallpaper.contentObject),
encryption = chatWallpaper.key?.let { key ->
chatWallpaper.iv?.let { iv ->
(key to iv).toKeyPair()
}
},
type = DownloadMediaType.PROTO_MEDIA
)
)
)
event.view.triggerCloseTouchEvent()
}
shape = shape,
color = Color.Transparent,
border = BorderStroke(1.dp, stroke),
shadowElevation = 6.dp,
tonalElevation = 0.dp
) {
Text(context.translation["chat_wallpaper_downloader.download_button"])
Row(
modifier = Modifier
.background(background, shape)
.clickable {
val friendInfo = runCatching {
context.database.getDMOtherParticipant(conversationId)?.let {
context.database.getFriendInfo(it)
} ?: context.database.getFriendInfo(context.database.myUserId)
}.getOrNull()
context.feature(MediaDownloader::class).provideDownloadManagerClient(
mediaIdentifier = chatWallpaper.contentObject.contentHashCode().absoluteValue.toString(16),
mediaAuthor = friendInfo?.mutableUsername ?: "unknown",
downloadSource = MediaDownloadSource.CHAT_WALLPAPER,
friendInfo = friendInfo,
).downloadInputMedias(
arrayOf(
InputMedia(
content = Base64.UrlSafe.encode(chatWallpaper.contentObject),
encryption = chatWallpaper.key?.let { key ->
chatWallpaper.iv?.let { iv ->
(key to iv).toKeyPair()
}
},
type = DownloadMediaType.PROTO_MEDIA
)
)
)
event.view.triggerCloseTouchEvent()
}
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
imageVector = Icons.Filled.DownloadForOffline,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = label,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
}.apply {
layoutParams = ViewGroup.LayoutParams(

View File

@@ -3,8 +3,11 @@ package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -12,7 +15,8 @@ import androidx.compose.runtime.*
import androidx.core.app.NotificationCompat
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -33,6 +37,8 @@ import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEven
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.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -545,75 +551,138 @@ class SendOverride : Feature("Send Override") {
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
val mainTranslation = remember {
context.translation.getCategory("send_override_dialog")
}
PurrfectOverlayTheme {
val mainTranslation = remember {
context.translation.getCategory("send_override_dialog")
}
val dialogShape = RoundedCornerShape(24.dp)
val dialogSurfaceColor = Color(0xFF2A2452)
val border = remember {
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
)
)
}
val dialogBackground = remember {
Brush.linearGradient(
listOf(
Color(0xFF2A2452),
Color(0xFF1A143A)
)
)
}
@Composable
fun ActionTile(
modifier: Modifier = Modifier,
selected: Boolean = false,
icon: ImageVector,
title: String,
enabled: Boolean = true,
onClick: () -> Unit
) {
Card(
modifier = modifier.then(if (!enabled) Modifier.alpha(0.5f) else Modifier),
onClick = { if (enabled) onClick() },
elevation = if (selected) CardDefaults.elevatedCardElevation(disabledElevation = 3.dp) else CardDefaults.cardElevation(),
colors = if (selected) CardDefaults.elevatedCardColors() else CardDefaults.cardColors()
@Composable
fun ActionTile(
modifier: Modifier = Modifier,
selected: Boolean = false,
icon: ImageVector,
title: String,
onClick: () -> Unit
) {
Card(
modifier = modifier,
onClick = onClick,
shape = RoundedCornerShape(18.dp),
elevation = CardDefaults.cardElevation(defaultElevation = if (selected) 4.dp else 1.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) Color(0xFF3E3478) else Color(0xFF2F2A5B),
contentColor = Color.White
),
border = if (selected) BorderStroke(1.dp, PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.6f)) else null
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = title,
modifier = Modifier.size(28.dp),
tint = if (selected) PurrfectOverlayPalette.glowSecondary else Color.White.copy(alpha = 0.9f)
)
Spacer(Modifier.height(6.dp))
Text(
title,
modifier = Modifier.fillMaxWidth(),
fontSize = 12.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
softWrap = true,
lineHeight = 14.sp,
textAlign = TextAlign.Center
)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = dialogShape,
color = dialogSurfaceColor,
tonalElevation = 0.dp,
shadowElevation = 18.dp,
border = BorderStroke(1.dp, border)
) {
Column(
modifier = Modifier
.padding(16.dp)
.size(75.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.background(dialogBackground, dialogShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(icon, contentDescription = title, modifier = Modifier
.size(32.dp)
.padding(4.dp))
Text(title, modifier = Modifier.fillMaxWidth(), fontSize = 12.sp, fontWeight = FontWeight.Light, softWrap = true, lineHeight = 14.sp, textAlign = TextAlign.Center)
}
}
}
val translation = remember {
context.translation.getCategory("features.options.gallery_media_send_override")
}
var scheduleEnabled by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val translation = remember {
context.translation.getCategory("features.options.gallery_media_send_override")
}
var scheduleEnabled by remember { mutableStateOf(false) }
Text(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
text = "Send as ${translation[selectedType]}",
modifier = Modifier.padding(5.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
ActionTile(
modifier = Modifier.weight(1f).height(92.dp),
selected = selectedType == "ORIGINAL",
icon = Icons.Filled.Photo,
title = translation["ORIGINAL"]
) {
selectedType = "ORIGINAL"
}
ActionTile(
modifier = Modifier.weight(1f).height(92.dp),
selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP",
icon = Icons.Filled.PhotoCamera,
title = translation["SNAP"]
) {
selectedType = "SNAP"
}
ActionTile(
modifier = Modifier.weight(1f).height(92.dp),
selected = selectedType == "NOTE",
icon = Icons.Filled.MusicNote,
title = translation["NOTE"]
) {
selectedType = "NOTE"
}
}
Text(fontSize = 20.sp, fontWeight = FontWeight.Medium, text = "${mainTranslation["send_as"]} ${
translation[selectedType]}", modifier = Modifier.padding(5.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ActionTile(selected = selectedType == "ORIGINAL", icon = Icons.Filled.Photo, title = translation["ORIGINAL"], enabled = true) {
selectedType = "ORIGINAL"
}
ActionTile(selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP", icon = Icons.Filled.PhotoCamera, title = translation["SNAP"], enabled = true) {
selectedType = "SNAP"
}
ActionTile(selected = selectedType == "NOTE", icon = Icons.Filled.MusicNote, title = translation["NOTE"], enabled = true) {
selectedType = "NOTE"
}
}
fun convertDuration(duration: Float) = when {
duration in -2f..-1f -> 100
duration in -1f..-0f -> 250
duration in -0f..1f -> 500
duration >= 11f -> null
else -> ((duration * 1000).toInt() / 1000) * 1000
}
fun convertDuration(duration: Float) = when {
duration in -2f..-1f -> 100
duration in -1f..-0f -> 250
duration in -0f..1f -> 500
duration >= 11f -> null
else -> ((duration * 1000).toInt() / 1000) * 1000
}
fun formatTimeText(ms: Long): String {
val days = (ms / (24 * 60 * 60 * 1000)).toInt()
@@ -937,6 +1006,8 @@ class SendOverride : Feature("Send Override") {
}
}
}
}
}
}.show()
}
}

View File

@@ -3,22 +3,14 @@ package me.eternal.purrfectsnap.core.ui
import android.app.Activity
import android.view.View
import android.widget.FrameLayout
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
@@ -29,21 +21,25 @@ 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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
import me.eternal.purrfectsnap.core.event.Event
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -56,9 +52,21 @@ import kotlin.system.exitProcess
typealias CustomComposable = @Composable BoxScope.() -> Unit
class CallRecorderUIState {
var isRecording by mutableStateOf(false)
var showOverlay by mutableStateOf(false)
var currentAuthor by mutableStateOf("")
var recordingStartTime by mutableStateOf(0L)
var offsetX by mutableStateOf(0f)
var offsetY by mutableStateOf(0f)
var isMinimized by mutableStateOf(false)
var lastInteractionTime by mutableStateOf(0L)
}
class InAppOverlay(
private val context: ModContext
) {
val callRecorderState = CallRecorderUIState()
companion object {
fun showCrashOverlay(content: String, throwable: Throwable? = null) {
// deny network requests
@@ -75,59 +83,67 @@ class InAppOverlay(
val contentView = param.thisObject<Activity>().findViewById<FrameLayout>(android.R.id.content)
contentView.children().forEach { it.visibility = View.GONE }
val screenView = createComposeView(param.thisObject()) {
PurrfectOverlayTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectOverlayPalette.backgroundGradient),
contentAlignment = Alignment.Center
AppMaterialTheme(isDarkTheme = true) {
val auroraGradient = Brush.verticalGradient(
listOf(Color(0xFF2E2E69), Color(0xFF1E1E45))
)
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Transparent
) {
PurrfectGlassCard(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
title = "PurrfectSnap",
subtitle = content,
icon = Icons.Outlined.Warning
.fillMaxSize()
.background(auroraGradient),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (throwable != null) {
Surface(
onClick = { contentView.context.copyToClipboard(throwable.stackTraceToString()) },
shape = RoundedCornerShape(999.dp),
color = Color.White.copy(alpha = 0.10f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
"Copy error",
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
color = Color.White
)
}
}
Surface(
onClick = { exitProcess(1) },
shape = RoundedCornerShape(999.dp),
color = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.28f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.7f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
)
)
)
Text(
text = "PurrfectSnap",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(40.dp))
Text(
text = content,
fontSize = 18.sp,
color = Color.White.copy(alpha = 0.9f)
)
Spacer(modifier = Modifier.height(60.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
Text(
"Exit",
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
color = Color.White,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
throwable?.let {
Button(
onClick = {
contentView.context.copyToClipboard(it.stackTraceToString())
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.1f),
contentColor = Color.White
)
) {
Text("Copy error")
}
}
Button(
onClick = {
exitProcess(1)
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Exit App")
}
}
}
}
@@ -155,80 +171,365 @@ class InAppOverlay(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OverlayContent() {
CompositionLocalProvider(
LocalContentColor provides Color.White,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(color = Color.White))
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding(),
) {
toasts.forEach { toast ->
val animation by animateFloatAsState(
targetValue = if (toast.visible) 1f else 0f,
animationSpec = if (toast.visible) tween(durationMillis = 150) else tween(durationMillis = 300),
label = "toast"
)
toasts.forEach { toast ->
val animation by animateFloatAsState(
targetValue = if (toast.visible) 1f else 0f,
animationSpec = if (toast.visible) tween(durationMillis = 150) else tween(durationMillis = 300),
label = "toast"
)
LaunchedEffect(toast) {
toast.visible = true
if (toast.durationMs < 0) return@LaunchedEffect
delay(toast.durationMs.toLong())
toast.visible = false
delay(1000)
toast.shown = true
synchronized(toasts) {
if (toasts.isNotEmpty() && toasts.all { it.shown }) toasts.clear()
}
}
val deviceWidth = LocalContext.current.resources.displayMetrics.widthPixels
val delayAnimationSpec = rememberSplineBasedDecay<Float>()
val anchors = DraggableAnchors {
0 at 0f
1 at deviceWidth.toFloat()
}
val draggableState = remember {
AnchoredDraggableState(
initialValue = 0,
anchors = anchors
)
}
LaunchedEffect(draggableState.currentValue) {
if (draggableState.currentValue == 1) {
toast.visible = false
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.anchoredDraggable(
state = draggableState,
orientation = Orientation.Horizontal
)
.offset { IntOffset(draggableState.offset.roundToInt(), 0) }
.graphicsLayer {
alpha = animation
translationY = -100.dp.toPx() * (1 - animation)
}
) {
if (animation > 0.01f) {
toast.composable(toast)
}
LaunchedEffect(toast) {
toast.visible = true
if (toast.durationMs < 0) return@LaunchedEffect
delay(toast.durationMs.toLong())
toast.visible = false
delay(1000)
toast.shown = true
synchronized(toasts) {
if (toasts.isNotEmpty() && toasts.all { it.shown }) toasts.clear()
}
}
customComposables.forEach {
it()
val deviceWidth = LocalContext.current.resources.displayMetrics.widthPixels
val delayAnimationSpec = rememberSplineBasedDecay<Float>()
val draggableState = remember {
AnchoredDraggableState(
initialValue = 0,
anchors = DraggableAnchors {
-1 at -deviceWidth.toFloat()
0 at 0f
1 at deviceWidth.toFloat()
},
confirmValueChange = {
if (it == 0) return@AnchoredDraggableState true
toast.visible = false
true
}
)
}
val flingBehavior = AnchoredDraggableDefaults.flingBehavior(draggableState)
Box(
modifier = Modifier
.fillMaxWidth()
.anchoredDraggable(
state = draggableState,
orientation = Orientation.Horizontal,
flingBehavior = flingBehavior
)
.offset { IntOffset(draggableState.offset.roundToInt(), 0) }
.graphicsLayer {
alpha = animation
translationY = -100.dp.toPx() * (1 - animation)
}
) {
if (animation > 0.01f) {
toast.composable(toast)
}
}
}
customComposables.forEach {
it()
}
CallRecorderOverlay()
}
}
@Composable
private fun CallRecorderOverlay() {
var elapsedTime by remember { mutableStateOf(0L) }
val density = LocalDensity.current
val screenWidth = LocalContext.current.resources.displayMetrics.widthPixels.toFloat()
// Update timer every second
LaunchedEffect(callRecorderState.isRecording) {
if (callRecorderState.isRecording) {
while (true) {
delay(1000)
elapsedTime = System.currentTimeMillis() - callRecorderState.recordingStartTime
}
} else {
elapsedTime = 0L
}
}
// Auto-minimize logic
LaunchedEffect(callRecorderState.showOverlay, callRecorderState.lastInteractionTime) {
if (callRecorderState.showOverlay && !callRecorderState.isMinimized) {
delay(10000)
callRecorderState.isMinimized = true
}
}
// Reset interaction time when shown
LaunchedEffect(callRecorderState.showOverlay) {
if (callRecorderState.showOverlay) {
callRecorderState.lastInteractionTime = System.currentTimeMillis()
callRecorderState.isMinimized = false
}
}
val displayOffsetX by animateFloatAsState(
targetValue = if (callRecorderState.isMinimized) {
-screenWidth / 2f + with(density) { 24.dp.toPx() }
} else {
callRecorderState.offsetX
},
label = "offsetX"
)
val displayOffsetY by animateFloatAsState(
targetValue = if (callRecorderState.isMinimized) 0f else callRecorderState.offsetY,
label = "offsetY"
)
// Pulsing animation for recording indicator
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 2.5f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseScale"
)
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 0.6f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "pulseAlpha"
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = callRecorderState.showOverlay,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut(animationSpec = tween(durationMillis = 150))
) {
val uiDesign = context.config.downloader.callRecorder.callRecorderUiDesign.get()
val isDark = context.mainActivity?.isDarkTheme() != false
val containerColor = when (uiDesign) {
"default" -> if (isDark) Color(0xFF2D2D30) else Color(0xFFEFEFF0)
"cyber" -> Color(0xFF000000)
"frost" -> Color(0xB3FFFFFF)
"snapchat" -> Color(0xFFFFFC00)
else -> if (isDark) Color(0xFF2D2D30) else Color(0xFFEFEFF0)
}
val contentColor = when (uiDesign) {
"default" -> if (isDark) Color(0xFFE4E4E4) else Color(0xFF1F1F1F)
"frost" -> Color(0xFF333333)
"snapchat" -> Color.Black
else -> Color.White
}
val buttonBackground = when (uiDesign) {
"default" -> if (isDark) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f)
"cyber" -> Color(0xFF00E5FF).copy(alpha = 0.1f)
"frost" -> Color.Black.copy(alpha = 0.1f)
"snapchat" -> Color.Black
else -> if (isDark) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.08f)
}
val buttonIconColor = when (uiDesign) {
"default" -> if (isDark) Color(0xFFE4E4E4) else Color(0xFF1F1F1F)
"snapchat" -> Color.White
"frost" -> Color(0xFF333333)
"cyber" -> Color(0xFF00E5FF)
else -> Color.White
}
val auroraGradient = Brush.horizontalGradient(
listOf(
Color(0xFF6F28A8),
Color(0xFF0059B7)
)
)
Card(
modifier = Modifier
.offset { IntOffset(displayOffsetX.roundToInt(), displayOffsetY.roundToInt()) }
.shadow(
elevation = 16.dp,
shape = CircleShape,
ambientColor = if (uiDesign == "cyber") Color(0xFF00E5FF) else Color.Black,
spotColor = if (uiDesign == "cyber") Color(0xFF00E5FF) else Color.Black
)
.then(when (uiDesign) {
"cyber" -> Modifier.background(
color = Color(0xFF00E5FF).copy(alpha = 0.4f),
shape = CircleShape
).padding(1.dp)
"frost" -> Modifier.background(
color = Color.White.copy(alpha = 0.3f),
shape = CircleShape
).padding(0.5.dp)
else -> Modifier
})
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
callRecorderState.isMinimized = false
callRecorderState.lastInteractionTime = System.currentTimeMillis()
},
onDrag = { change, dragAmount ->
change.consume()
callRecorderState.offsetX += dragAmount.x
callRecorderState.offsetY += dragAmount.y
callRecorderState.lastInteractionTime = System.currentTimeMillis()
}
)
}
.pointerInput(Unit) {
detectTapGestures {
if (callRecorderState.isMinimized) {
callRecorderState.isMinimized = false
}
callRecorderState.lastInteractionTime = System.currentTimeMillis()
}
}
.wrapContentSize(),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = if (uiDesign == "default") Color.Transparent else containerColor
),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Box(modifier = Modifier.then(
if (uiDesign == "default") Modifier.background(auroraGradient) else Modifier
)) {
AnimatedContent(
targetState = callRecorderState.isMinimized,
label = "minimized"
) { minimized ->
if (minimized) {
Box(
modifier = Modifier.padding(12.dp),
contentAlignment = Alignment.Center
) {
if (callRecorderState.isRecording) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(10.dp)
.scale(pulseScale)
.background(Color.Red.copy(alpha = pulseAlpha), CircleShape)
)
Box(
modifier = Modifier
.size(10.dp)
.background(Color.Red, CircleShape)
)
}
} else {
Box(
modifier = Modifier
.size(10.dp)
.background(contentColor.copy(alpha = 0.5f), CircleShape)
)
}
}
} else {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// Timer and Status
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val seconds = (elapsedTime / 1000) % 60
val minutes = (elapsedTime / 1000) / 60
Row(verticalAlignment = Alignment.CenterVertically) {
if (callRecorderState.isRecording) {
Box(contentAlignment = Alignment.Center) {
// Pulsing Outer Ring
Box(
modifier = Modifier
.size(6.dp)
.scale(pulseScale)
.background(Color.Red.copy(alpha = pulseAlpha), CircleShape)
)
// Solid Inner Core
Box(
modifier = Modifier
.size(6.dp)
.background(Color.Red, CircleShape)
)
}
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = String.format("%02d:%02d", minutes, seconds),
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
color = contentColor
)
}
}
// Vertical Separator
Box(
modifier = Modifier
.width(1.5.dp)
.height(20.dp)
.background(contentColor.copy(alpha = 0.2f))
)
// Control Button
Box(
modifier = Modifier
.size(32.dp)
.background(buttonBackground, CircleShape)
.pointerInput(callRecorderState.isRecording) {
detectTapGestures {
callRecorderState.lastInteractionTime = System.currentTimeMillis()
context.event.post(CallRecorderControlEvent(!callRecorderState.isRecording))
}
},
contentAlignment = Alignment.Center
) {
if (callRecorderState.isRecording) {
Box(
modifier = Modifier
.size(10.dp)
.background(buttonIconColor, RoundedCornerShape(1.dp))
)
} else {
Box(
modifier = Modifier
.size(10.dp)
.background(buttonIconColor, CircleShape)
)
}
}
}
}
}
}
}
}
}
}
class CallRecorderControlEvent(val start: Boolean) : Event()
private val overlayTag = Random.nextLong()
private fun injectOverlay(activity: Activity) {
@@ -236,7 +537,9 @@ class InAppOverlay(
activity.runOnUiThread {
if (root.findViewWithTag<View>(overlayTag) != null) return@runOnUiThread
root.addView(createComposeView(activity) {
PurrfectOverlayTheme { OverlayContent() }
AppMaterialTheme(isDarkTheme = remember { activity.isDarkTheme() }) {
OverlayContent()
}
}.apply {
tag = overlayTag
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
@@ -272,9 +575,7 @@ class InAppOverlay(
LinearProgressIndicator(
progress = { progress.value },
modifier = modifier,
color = PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.85f),
trackColor = Color.White.copy(alpha = 0.10f),
modifier = modifier
)
}
@@ -285,26 +586,10 @@ class InAppOverlay(
showDuration: Boolean = true,
maxLines: Int = 3
) {
if (context.config.global.uiSettings.useSystemToasts.get()) {
if (durationMs > 2500) {
context.longToast(text)
} else {
context.shortToast(text)
}
return
}
showToast(
icon = { Icon(icon, contentDescription = "icon", modifier = Modifier.size(32.dp)) },
text = {
Text(
text,
modifier = Modifier.fillMaxWidth(),
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
lineHeight = 15.sp,
fontSize = 13.sp,
color = Color.White
)
Text(text, modifier = Modifier.fillMaxWidth(), maxLines = maxLines, overflow = TextOverflow.Ellipsis, lineHeight = 15.sp, fontSize = 13.sp)
},
durationMs = durationMs,
showDuration = showDuration
@@ -319,63 +604,42 @@ class InAppOverlay(
durationMs: Int = 3000,
showDuration: Boolean = true,
) {
val activity = context.mainActivity ?: return
injectOverlay(activity)
injectOverlay(context.mainActivity!!)
toasts.add(Toast(
composable = {
val shape = RoundedCornerShape(18.dp)
Surface(
val isDark = LocalContext.current.isDarkTheme()
val auroraGradient = Brush.verticalGradient(
listOf(
if (isDark) Color(0xFF2E2E69) else Color(0xFFE9DDFF),
if (isDark) Color(0xFF1B1B4D) else Color(0xFFF9F8FF)
)
)
ElevatedCard(
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
.padding(12.dp)
.shadow(12.dp, MaterialTheme.shapes.large)
.fillMaxWidth()
.shadow(
elevation = 18.dp,
shape = shape,
spotColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.22f),
ambientColor = PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.16f)
)
.clip(shape)
.background(PurrfectOverlayPalette.cardOverlay, shape)
.border(
BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f),
)
)
),
shape
),
color = Color.Transparent,
contentColor = Color.White,
tonalElevation = 0.dp,
shadowElevation = 0.dp
.clip(MaterialTheme.shapes.large),
colors = CardDefaults.elevatedCardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Column {
Box(modifier = Modifier.background(auroraGradient)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(16.dp)
) {
Box(
modifier = Modifier
.size(42.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
icon()
}
icon()
text()
}
if (showDuration && durationMs > 0) {
DurationProgress(duration = durationMs, modifier = Modifier.fillMaxWidth())
}
}
if (showDuration && durationMs > 0) {
DurationProgress(duration = durationMs, modifier = Modifier.fillMaxWidth())
}
}
},
@@ -390,7 +654,7 @@ class InAppOverlay(
return
}
val animationType = BypassAnimation.entries.random()
val animationType = BypassAnimation.values().random()
lateinit var composable: CustomComposable
composable = {
@@ -430,81 +694,34 @@ class InAppOverlay(
alpha = progress
}
) {
// Use PurrfectSnap UI colors - gradient for active, red tint for inactive
val backgroundColor = if (isWorking) {
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.85f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.75f)
)
)
} else {
Brush.linearGradient(
listOf(
Color(0xFFB71C1C).copy(alpha = 0.85f),
Color(0xFFD32F2F).copy(alpha = 0.75f)
)
)
}
val backgroundColor = if (isWorking) Color(0xFF1B5E20).copy(alpha = 0.8f) else Color(0xFFB71C1C).copy(alpha = 0.8f)
val shape = RoundedCornerShape(24.dp)
Surface(
Row(
modifier = Modifier
.shadow(
elevation = 12.dp,
shape = shape,
spotColor = if (isWorking) PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.3f) else Color(0xFFB71C1C).copy(alpha = 0.25f),
ambientColor = if (isWorking) PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.2f) else Color(0xFFD32F2F).copy(alpha = 0.18f)
.background(
color = backgroundColor,
shape = MaterialTheme.shapes.large
)
.clip(shape)
.background(backgroundColor, shape)
.border(
BorderStroke(
1.dp,
if (isWorking) {
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.7f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.6f)
)
)
} else {
Brush.linearGradient(
listOf(
Color(0xFFB71C1C).copy(alpha = 0.7f),
Color(0xFFD32F2F).copy(alpha = 0.6f)
)
)
}
),
shape
),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 0.dp
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = if (isWorking) Icons.Filled.Check else Icons.Filled.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp)
)
Text(
text = if (isWorking)
context.translation.getOrNull("manager.sections.bypass_status.active") ?: "Bypass Active"
else
context.translation.getOrNull("manager.sections.bypass_status.inactive") ?: "Bypass Inactive",
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
Icon(
imageVector = if (isWorking) Icons.Filled.Check else Icons.Filled.Close,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
Text(
text = if (isWorking)
context.translation["manager.sections.bypass_status.active"]
else
context.translation["manager.sections.bypass_status.inactive"],
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
}
}
}

View File

@@ -8,8 +8,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.2.0
APP_VERSION_CODE=255
APP_VERSION_NAME=1.2.5
APP_VERSION_CODE=260
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=97fc5c4dff7e33c159528eb9e53f86c356d430cd63d177f40c22b577ac829dee