Multiple changes
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": "بحث"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "インフォメーション"
|
||||
},
|
||||
|
||||
@@ -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": "제품정보"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Пошук"
|
||||
},
|
||||
|
||||
@@ -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": "搜索"
|
||||
},
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user