v1.1.0: Stable!

This commit is contained in:
particle-box
2026-01-13 23:27:32 +05:30
parent dd27ed9450
commit 1bf6335ca2
305 changed files with 13061 additions and 5218 deletions

View File

@@ -110,13 +110,35 @@ android {
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
storePassword = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
keyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
keyPassword = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
}
}
defaultConfig {
val autoCertSha = providers.provider {
computeKeystoreCertSha256(
File(System.getProperty("user.home"), ".android/debug.keystore"),
storePass = "android",
keyAlias = "androiddebugkey"
).orEmpty()
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
val releaseKeyPass = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
if (!releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
computeKeystoreCertSha256(
releaseStore,
storePass = releaseStorePass,
keyAlias = releaseKeyAlias,
keyPass = releaseKeyPass ?: releaseStorePass
)
} else {
computeKeystoreCertSha256(
File(System.getProperty("user.home"), ".android/debug.keystore"),
storePass = "android",
keyAlias = "androiddebugkey"
)
}.orEmpty()
}
val expectedCertSha256 = providers.gradleProperty("EXPECTED_CERT_SHA256")
.orElse(providers.gradleProperty("psExpectedCertSha256"))
@@ -135,6 +157,7 @@ android {
release {
isMinifyEnabled = true
proguardFiles += file("proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
debug {
(properties["debug_flavor"] == null).also {

View File

@@ -16,8 +16,8 @@
android:usesCleartextTraffic="true"
android:label="@string/app_name"
tools:targetApi="34"
android:allowBackup="true"
android:hasFragileUserData="true"
android:allowBackup="false"
android:hasFragileUserData="false"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
@@ -58,11 +58,12 @@
<activity android:name=".bridge.ForceStartActivity"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:exported="false" />
android:exported="true" />
<activity android:name=".bridge.BiometricPromptActivity"
android:theme="@style/BiometricPromptTheme"
android:excludeFromRecents="true"
android:exported="false" />
android:exported="true"
android:permission="com.snapchat.android.permission.UPDATE_STICKER_INDEX" />
<receiver android:name=".StreaksReminder" />
<provider
android:name="androidx.core.content.FileProvider"

View File

@@ -104,10 +104,10 @@ class RemoteFileHandleManager(
File(userImportFolder, name.substringAfterLast("/"))
)
}
FileHandleScope.COMPOSER -> {
FileHandleScope.VALDI -> {
AssetFileHandle(
context,
"composer/${name.substringAfterLast("/")}"
"valdi/${name.substringAfterLast("/")}"
)
}
}

View File

@@ -431,13 +431,15 @@ class DownloadProcessor (
return@let
}
}
callbackOnFailure(translation["already_downloaded_toast"], null)
callbackOnFailure(translation["already_downloaded_toast"])
return@launch
} else {
callbackOnFailure(translation["already_queued_toast"], null)
}
return@launch
}
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(

View File

@@ -111,6 +111,11 @@ class FFMpegProcessor(
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}
private fun isMediaCodecFailure(output: String): Boolean {
val lower = output.lowercase()
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
}
suspend fun execute(args: Request) {
// load ffmpeg native sync to avoid native crash
synchronized(this) { FFmpegKit.listSessions() }
@@ -222,6 +227,20 @@ class FFMpegProcessor(
}
}
outputArguments += args.output.absolutePath
newFFMpegTask(globalArguments, inputArguments, outputArguments)
try {
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} catch (e: Exception) {
val output = e.message.orEmpty()
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
outputArguments -= "-c:v"
outputArguments += "-c:v" to "libx264"
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} else {
throw e
}
}
}
}
}

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.setup.patch
import com.google.gson.JsonParser
import java.util.concurrent.TimeUnit
import kotlin.random.Random
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -29,7 +30,7 @@ class AutoPatchServer(
fun fetchLatestSnapchatApk(): LatestApk? {
val request = Request.Builder()
.url("https://api.github.com/repos/particle-box/auto-patch-server/releases/latest")
.url("https://api.github.com/repos/particle-box/download-snap/releases/latest")
.build()
okHttpClient.newCall(request).execute().use { response ->
@@ -47,9 +48,9 @@ class AutoPatchServer(
name to downloadUrl
}
val selected = apkAssets.firstOrNull { it.first.contains("snapchat", ignoreCase = true) }
?: apkAssets.firstOrNull()
?: return null
val nonPrimary = apkAssets.filterNot { it.first.contains("snapchat", ignoreCase = true) }
val selectionPool = if (nonPrimary.isNotEmpty()) nonPrimary else apkAssets
val selected = selectionPool.random(Random.Default)
return LatestApk(
tagName = tagName,
@@ -59,4 +60,3 @@ class AutoPatchServer(
}
}
}

View File

@@ -11,9 +11,9 @@ data class PatchConfig(
) {
data class LSPConfig(
var API_CODE: Int = 93,
var VERSION_CODE: Int = 360,
var VERSION_NAME: String = "0.5.1",
var CORE_VERSION_CODE: Int = 6649,
var CORE_VERSION_NAME: String = "1.8.5",
var VERSION_CODE: Int = 430,
var VERSION_NAME: String = "0.7",
var CORE_VERSION_CODE: Int = 7137,
var CORE_VERSION_NAME: String = "1.10.1",
)
}

View File

@@ -16,6 +16,7 @@ import androidx.work.WorkerParameters
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.MainActivity
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
class UpdateCheckWorker(
private val appContext: Context,
@@ -24,7 +25,11 @@ class UpdateCheckWorker(
override suspend fun doWork(): Result {
return try {
val latestRelease = Updater.latestRelease
val channel = when (inputData.getString("update_channel")) {
"prerelease" -> Channel.PRERELEASE
else -> Channel.STABLE
}
val latestRelease = Updater.getLatestRelease(channel)
if (latestRelease != null) {
showUpdateNotification(latestRelease.versionName)
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -174,7 +175,22 @@ class MainActivity : ComponentActivity() {
} else {
PaddingValues(0.dp)
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(statusBarHeight + 24.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color(0xFF241F52),
Color.Transparent
)
)
)
)
if (!isFullscreen) {
Box(
modifier = Modifier

View File

@@ -332,8 +332,7 @@ class Navigation(
}
val horizontalInset = 2.dp
val indicatorWidth = (with(density) { itemWidthPx.toDp() } - horizontalInset * 2)
.coerceAtLeast(70.dp)
.coerceAtMost(with(density) { itemWidthPx.toDp() })
.coerceAtLeast(0.dp)
Box(
modifier = Modifier
.fillMaxSize()

View File

@@ -19,8 +19,10 @@ import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.ManageRuleFeature
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.pages.home.RetroGameScreen
import me.eternal.purrfectsnap.ui.manager.pages.location.BetterLocationRoot
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.LoggedStories
@@ -67,8 +69,8 @@ class Routes(
var friendTrackerConfigJsonForImport: String? = null
var onRuleImported: (() -> Unit)? = null
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true, hasOwnTopBar = true), TasksRootSection())
@@ -76,6 +78,8 @@ class Routes(
val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}", hasOwnTopBar = true), ManageRuleFeature()).parent(features)
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true, hasOwnTopBar = true), HomeRootSection())
val about = route(RouteInfo("home_about", hasOwnTopBar = true), HomeAbout()).parent(home)
val retroGame = route(RouteInfo("retro_game", hasOwnTopBar = true), RetroGameScreen()).parent(home)
val settings = route(RouteInfo("home_settings", hasOwnTopBar = true), HomeSettings()).parent(home)
val homeLogs = route(RouteInfo("home_logs", hasOwnTopBar = true), HomeLogs()).parent(home)
val loggerHistory = route(RouteInfo("logger_history", hasOwnTopBar = true), LoggerHistoryRoot()).parent(home)
@@ -87,7 +91,7 @@ class Routes(
val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog", hasOwnTopBar = true), FriendTrackerCatalog())
val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos", hasOwnTopBar = true), ManageFriendTrackerReposSection())
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
val fileImports = route(RouteInfo("file_imports", hasOwnTopBar = true), FileImportsRoot()).parent(home)
val manageRepos = route(RouteInfo("manage_repos/?type={type}"), ManageReposSection())
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true, hasOwnTopBar = true), SocialRootSection())

View File

@@ -62,7 +62,9 @@ fun AestheticDialog(
loading: Boolean = false,
opaque: Boolean = false,
showCloseButton: Boolean = true,
confirmEnabled: Boolean = true
confirmEnabled: Boolean = true,
showIcon: Boolean = true,
showTitle: Boolean = true
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
@@ -94,28 +96,32 @@ fun AestheticDialog(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
if (showIcon) {
Box(
modifier = Modifier
.size(62.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
}
}
if (showTitle && title.isNotBlank()) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
if (text.isNotBlank()) {
Text(
text = text,
@@ -130,25 +136,27 @@ fun AestheticDialog(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
) {
if (dismissButtonText != null && onDismiss != null) {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) { Text(dismissButtonText) }
}
Button(
onClick = onConfirm,
enabled = confirmEnabled && !loading,
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
) { Text(dismissButtonText) }
}
Button(
onClick = onConfirm,
enabled = confirmEnabled && !loading,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White,
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
disabledContentColor = Color.White.copy(alpha = 0.75f)
)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = Color.White
)

View File

@@ -1,11 +1,11 @@
package me.eternal.purrfectsnap.ui.manager.data
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import com.tonyodev.fetch2.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -15,13 +15,14 @@ import java.io.FileOutputStream
import java.util.zip.ZipInputStream
object UpdateDownloader {
private const val TAG = "UpdateDownloader"
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private fun getInstance(context: Context): Fetch {
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
fetch = run {
val fetchConfiguration = FetchConfiguration.Builder(context.applicationContext)
val fetchConfiguration = FetchConfiguration.Builder(context.androidContext)
.setDownloadConcurrentLimit(3)
.build()
Fetch.getInstance(fetchConfiguration)
@@ -56,14 +57,43 @@ object UpdateDownloader {
}
}
private fun resolveDownloadedApk(
remoteContext: RemoteSideContext,
downloadedFile: File
): File {
val context = remoteContext.androidContext
if (downloadedFile.extension.equals("zip", ignoreCase = true)) {
val unzipDir = File(context.externalCacheDir, "update")
if (unzipDir.exists()) unzipDir.deleteRecursively()
unzipDir.mkdirs()
remoteContext.log.info(
"Extracting update archive ${downloadedFile.absolutePath} -> ${unzipDir.absolutePath}",
TAG
)
unzip(downloadedFile, unzipDir)
return unzipDir.walk().firstOrNull { it.isFile && it.extension.equals("apk", true) }
?: throw IllegalStateException("No APK found in the downloaded archive")
}
if (!downloadedFile.extension.equals("apk", ignoreCase = true)) {
remoteContext.log.warn(
"Downloaded file is not an APK or ZIP (${downloadedFile.name}); attempting installation anyway.",
TAG
)
}
return downloadedFile
}
fun downloadAndInstall(
context: Context,
remoteContext: RemoteSideContext,
downloadUrl: String,
fileName: String,
scope: CoroutineScope
) {
val fetch = getInstance(context)
val context = remoteContext.androidContext
val fetch = getInstance(remoteContext)
val filePath = File(context.externalCacheDir, fileName).path
remoteContext.log.info("Starting update download from $downloadUrl -> $filePath", TAG)
val request = Request(downloadUrl, filePath).apply {
priority = Priority.HIGH
networkType = NetworkType.ALL
@@ -72,6 +102,7 @@ object UpdateDownloader {
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
remoteContext.log.info("Queued update download: ${download.file}", TAG)
}
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
@@ -85,26 +116,36 @@ object UpdateDownloader {
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
runCatching {
val downloadedFile = File(download.file)
val unzipDir = File(context.externalCacheDir, "update")
if (unzipDir.exists()) {
unzipDir.deleteRecursively()
}
unzipDir.mkdirs()
unzip(downloadedFile, unzipDir)
val apkFile = unzipDir.walk().find { it.isFile && it.extension == "apk" }
?: throw Exception("No APK found in the downloaded file")
val uri = FileProvider.getUriForFile(context, "me.eternal.purrfectsnap.fileprovider", apkFile)
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
it.printStackTrace()
Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
fetch.removeListener(this)
@@ -117,6 +158,8 @@ object UpdateDownloader {
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(context, "Download failed: $error", Toast.LENGTH_SHORT).show()
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
?: remoteContext.log.error("Update download failed: $error", TAG)
fetch.removeListener(this)
scope.launch {
delay(2000)

View File

@@ -8,13 +8,34 @@ import okhttp3.Request
object Updater {
enum class Channel { STABLE, PRERELEASE }
data class LatestRelease(
val versionName: String,
val releaseUrl: String,
val workflowId: Long?,
val assetDownloads: Map<String, String> = emptyMap(),
)
private fun fetchLatestRelease() = runCatching {
private fun isVersionGreater(version: String, current: String): Boolean {
fun segments(raw: String) = raw
.split(Regex("[^0-9]+"))
.filter { it.isNotBlank() }
.map { it.toIntOrNull() ?: 0 }
val v = segments(version)
val c = segments(current)
val size = maxOf(v.size, c.size)
for (i in 0 until size) {
val vi = v.getOrElse(i) { 0 }
val ci = c.getOrElse(i) { 0 }
if (vi > ci) return true
if (vi < ci) return false
}
return false
}
private fun fetchLatestRelease(channel: Channel) = runCatching {
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
@@ -24,17 +45,45 @@ object Updater {
if (it.size() == 0) throw Throwable("No releases found")
}
val latestRelease = releases.get(0).asJsonObject
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString
if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return@runCatching null
val currentVersion = BuildConfig.VERSION_NAME
val latestRelease = releases.mapNotNull { it.asJsonObject }.firstOrNull { release ->
if (release.get("draft")?.asBoolean != false) return@firstOrNull false
val matchesChannel = when (channel) {
Channel.STABLE -> release.get("prerelease")?.asBoolean == false
Channel.PRERELEASE -> release.get("prerelease")?.asBoolean == true
}
if (!matchesChannel) return@firstOrNull false
val latestVersion = release.getAsJsonPrimitive("tag_name")?.asString?.removePrefix("v") ?: return@firstOrNull false
isVersionGreater(latestVersion, currentVersion)
} ?: throw Throwable("No matching releases found for $channel channel")
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString.removePrefix("v")
if (latestVersion == BuildConfig.VERSION_NAME) return@runCatching null
val assets = latestRelease.getAsJsonArray("assets")?.mapNotNull { element ->
val obj = element.asJsonObject
val name = obj.getAsJsonPrimitive("name")?.asString?.lowercase() ?: return@mapNotNull null
val url = obj.getAsJsonPrimitive("browser_download_url")?.asString ?: return@mapNotNull null
name to url
} ?: emptyList()
val assetDownloads = buildMap<String, String> {
assets.forEach { (name, url) ->
when {
name.contains("arm64") || name.contains("armv8") -> put("arm64", url)
name.contains("armeabi") || name.contains("armv7") || name.contains("arm32") -> put("armv7", url)
}
}
}
LatestRelease(
versionName = latestVersion,
releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null
releaseUrl = latestRelease.getAsJsonPrimitive("html_url")?.asString
?: endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null,
assetDownloads = assetDownloads
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest release", it)
AbstractLogger.directError("Failed to fetch latest release ($channel)", it)
}.getOrNull()
private fun fetchLatestDebugCI() = runCatching {
@@ -60,7 +109,15 @@ object Updater {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull()
val latestRelease by lazy {
if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease()
private val cache = mutableMapOf<Channel, LatestRelease?>()
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
}
}
}

View File

@@ -4,6 +4,8 @@ import android.net.Uri
import android.text.format.Formatter
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,19 +22,24 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -45,6 +52,7 @@ import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.openFile
@@ -58,44 +66,75 @@ class FileImportsRoot: Routes.Route() {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@Composable
private fun ImportFab(onClick: () -> Unit) {
val shape = RoundedCornerShape(18.dp)
val border = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
PurrfectPalette.glowSecondary.copy(alpha = 0.6f)
)
)
val fill = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
)
Row(
modifier = Modifier
.shadow(
elevation = 12.dp,
shape = shape,
ambientColor = PurrfectPalette.glowSecondary.copy(alpha = 0.2f),
spotColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
)
.clip(shape)
.background(fill)
.border(1.dp, border, shape)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Default.Upload, contentDescription = null, tint = Color.White)
Text(
text = translation["import_file_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Row {
ExtendedFloatingActionButton(
icon = {
Icon(Icons.Default.Upload, contentDescription = null)
},
text = {
Text(translation["import_file_button"])
},
onClick = {
context.coroutineScope.launch {
activityLauncherHelper.openFile { filePath ->
val fileUri = Uri.parse(filePath)
runCatching {
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
if (!file.exists()) {
context.shortToast(translation["file_not_found"])
return@openFile
}
context.fileHandleManager.importFile(file.name!!) {
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
inputStream.copyTo(this)
}
ImportFab {
context.coroutineScope.launch {
activityLauncherHelper.openFile { filePath ->
val fileUri = Uri.parse(filePath)
runCatching {
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
if (!file.exists()) {
context.shortToast(translation["file_not_found"])
return@openFile
}
context.fileHandleManager.importFile(file.name!!) {
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
inputStream.copyTo(this)
}
}
}.onFailure {
context.log.error("Failed to import file", it)
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
}.onSuccess {
context.shortToast(translation["file_imported"])
coroutineScope.launch {
reloadDispatcher.dispatch()
}
}
}.onFailure {
context.log.error("Failed to import file", it)
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
}.onSuccess {
context.shortToast(translation["file_imported"])
coroutineScope.launch {
reloadDispatcher.dispatch()
}
}
}
})
}
}
}
@@ -103,78 +142,25 @@ class FileImportsRoot: Routes.Route() {
val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
context.fileHandleManager.getStoredFiles()
}
val density = LocalDensity.current
val titleText = context.translation["manager.routes.file_imports"]
var topBarHeight by remember { mutableStateOf(0.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier.fillMaxSize()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(
top = topBarHeight + 8.dp,
bottom = routes.bottomPadding + 16.dp
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 18.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = PurrfectPalette.cardOverlayColor
) {
Icon(
Icons.Default.AttachFile,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(10.dp)
)
}
Column {
Text(
text = translation["import_file_button"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = translation["manager.dialogs.file_imports.settings_select_file_hint"],
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 16.dp, top = 4.dp)
) {
item {
if (files.isEmpty()) {
Surface(
@@ -279,7 +265,15 @@ class FileImportsRoot: Routes.Route() {
}
}
}
}
FloatingTopBar(
title = titleText ?: translation["import_file_button"],
subtitle = null,
onBack = { routes.navController.popBackStack() },
modifier = Modifier.onGloballyPositioned {
topBarHeight = with(density) { it.size.height.toDp() }
}
)
}
}
}

View File

@@ -385,11 +385,12 @@ class LoggerHistoryRoot : Routes.Route() {
}
}
TextField(
OutlinedTextField(
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
placeholder = {
Text(
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",

View File

@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -29,6 +34,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
@@ -128,7 +135,8 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val exportSensitiveData = it.arguments?.getBoolean("exportSensitiveData") ?: false
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
val parser = remember { ConfigParser() }
val featuresByCategory = remember {
parser.parse(context.config.exportToString(exportSensitiveData))
@@ -140,11 +148,15 @@ class ConfigExportSummaryScreen : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
@@ -163,46 +175,36 @@ class ConfigExportSummaryScreen : Routes.Route() {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["back_button_description"],
tint = Color.White
)
}
Column {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
}
Surface(
shape = CircleShape,
color = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
IconButton(
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
@@ -220,14 +222,19 @@ class ConfigExportSummaryScreen : Routes.Route() {
)
}
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = translation["save_button"],
tint = Color.White
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
}
Text(exportLabel)
}
}
}

View File

@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -185,13 +190,18 @@ class ConfigImportConfirmationScreen : Routes.Route() {
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"] ?: "Import"
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -213,29 +223,34 @@ class ConfigImportConfirmationScreen : Routes.Route() {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["back_button_description"],
tint = Color.White
)
}
Column {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"] ?: "Back")
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = "Summary",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
@@ -261,7 +276,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
contentColor = Color.White
)
) {
Text(translation["confirm_button"])
Text(importLabel)
}
}
}
@@ -276,118 +291,118 @@ class ConfigImportConfirmationScreen : Routes.Route() {
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { expandedState[category] = !isExpanded },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
}
}
}
}
is String -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = parsedValue,
color = PurrfectPalette.glowSecondary,
textAlign = TextAlign.Start,
)
}
is String -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = parsedValue,
color = PurrfectPalette.glowSecondary,
textAlign = TextAlign.Start,
)
}
}
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun NumberBubble(number: Int) {

View File

@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
@@ -400,6 +401,7 @@ class FeaturesRootSection : Routes.Route() {
}
val propertyValue = property.value
fun persistConfig() = context.config.writeConfig()
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
registerDialogOnClickCallback()
@@ -412,11 +414,13 @@ class FeaturesRootSection : Routes.Route() {
isEmpty = it.isEmpty()
if (isEmpty) {
propertyValue.setAny(null)
persistConfig()
}
}
}
var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also {
if (files.isNotEmpty() && it == null) propertyValue.setAny(null)
if (files.isNotEmpty() && it == null) persistConfig()
}?.name) }
Surface(
@@ -487,6 +491,7 @@ class FeaturesRootSection : Routes.Route() {
.clickable {
selectedFile = if (isSelected) null else file.name
propertyValue.setAny(selectedFile)
persistConfig()
},
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.05f),
@@ -555,6 +560,7 @@ class FeaturesRootSection : Routes.Route() {
activityLauncher {
chooseFolder { uri ->
propertyValue.setAny(uri)
persistConfig()
}
}
}.let { { it.invoke(true) } }) {
@@ -575,6 +581,7 @@ class FeaturesRootSection : Routes.Route() {
}
state = state.not()
propertyValue.setAny(state)
persistConfig()
},
colors = purrfectSwitchColors()
)
@@ -722,6 +729,7 @@ class FeaturesRootSection : Routes.Route() {
}
state = state.not()
container.globalState = state
persistConfig()
},
colors = purrfectSwitchColors()
)
@@ -1518,6 +1526,7 @@ class FeaturesRootSection : Routes.Route() {
) {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(96.dp) }
val listState = rememberLazyListState()
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
@@ -1544,12 +1553,18 @@ class FeaturesRootSection : Routes.Route() {
liveSearchQuery = searchKeyword
}
}
LaunchedEffect(liveSearchQuery) {
if (!listState.isScrollInProgress) {
listState.scrollToItem(0)
}
}
Box(modifier = Modifier.fillMaxSize()) {
FeatureAuroraBackdrop()
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(
start = 6.dp,

View File

@@ -0,0 +1,334 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import android.os.SystemClock
class HomeAbout : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val scrollState = rememberScrollState()
val aboutStory = remember {
"""
PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, SUJΛL, Zain & scrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.
""".trimIndent()
}
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: "About",
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.panelGradient)
.padding(horizontal = 22.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "PurrfectSnap",
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
fontFamily = avenirNext,
modifier = Modifier.clickable(
interactionSource = tapSource,
indication = null
) {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
}
)
Text(
text = "An Xposed Module meant to enhance your Snapchat experience!",
fontSize = 13.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Lead Developers",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(top = 10.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(
name = "ΞTΞRNAL",
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = "<RSR/>",
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Our Story",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = aboutStory,
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp
)
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "With love, PurrfectSnap Team",
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
modifier = Modifier.weight(1f),
onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
private fun DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
modifier: Modifier = Modifier
) {
val cardShape = RoundedCornerShape(20.dp)
val imageRing = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary,
PurrfectPalette.glowSecondary
)
)
Surface(
modifier = modifier,
shape = cardShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(82.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape)
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
Text(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -226,6 +226,12 @@ class HomeLogs : Routes.Route() {
) {
val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } }
val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } }
val floatingButtonColors = IconButtonDefaults.filledIconButtonColors(
containerColor = PurrfectPalette.cardOverlayColor,
contentColor = Color.White,
disabledContainerColor = Color.White.copy(alpha = 0.08f),
disabledContentColor = Color.White.copy(alpha = 0.35f)
)
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
@@ -243,7 +249,8 @@ class HomeLogs : Routes.Route() {
logListState.scrollToItem(0)
}
},
enabled = firstVisibleItem != 0
enabled = firstVisibleItem != 0,
colors = floatingButtonColors
) {
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
}
@@ -253,7 +260,8 @@ class HomeLogs : Routes.Route() {
logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch)
}
},
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1,
colors = floatingButtonColors
) {
Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null)
}

View File

@@ -1,4 +1,4 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.content.SharedPreferences
import androidx.compose.animation.AnimatedContent
@@ -6,11 +6,8 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
@@ -19,7 +16,6 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -31,19 +27,24 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -55,18 +56,21 @@ import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
@@ -77,7 +81,6 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -91,9 +94,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -105,14 +106,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.navigation.NavBackStackEntry
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.action.EnumQuickActions
import me.eternal.purrfectsnap.common.BuildConfig
@@ -126,9 +127,13 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.AlertDialogs
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import okhttp3.OkHttpClient
import okhttp3.Request
class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
@@ -144,6 +149,10 @@ class HomeRootSection : Routes.Route() {
)
}
private val changelogClient by lazy { OkHttpClient() }
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
private val heroGradientColors = listOf(
Color(0xFF5C4B99),
Color(0xFF322B5E),
@@ -278,6 +287,26 @@ class HomeRootSection : Routes.Route() {
}
}
@Composable
private fun InfoCard(content: @Composable ColumnScope.() -> Unit) {
OutlinedCard(
modifier = Modifier
.padding(start = cardMargin, end = cardMargin)
.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp),
content = content
)
}
}
@Composable
private fun RowScope.HomeActionChips() {
TopBarActionChip(
@@ -373,6 +402,7 @@ class HomeRootSection : Routes.Route() {
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onWikiClick: () -> Unit,
onTelegramClick: () -> Unit,
@@ -382,6 +412,7 @@ class HomeRootSection : Routes.Route() {
avenirNext: FontFamily
) {
val heroShape = RoundedCornerShape(36.dp)
val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) }
Box(
modifier = Modifier
.padding(horizontal = cardMargin, vertical = 6.dp)
@@ -412,9 +443,10 @@ class HomeRootSection : Routes.Route() {
fontFamily = avenirNext
)
Text(
text = "by $authorName",
text = "By ΞTΞRNAL",
color = Color.White.copy(alpha = 0.75f),
fontSize = 14.sp
fontSize = 14.sp,
fontFamily = avenirNext
)
Text(
text = "An Xposed Module meant to enhance your Snapchat experience",
@@ -429,10 +461,119 @@ class HomeRootSection : Routes.Route() {
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge("Codename: Rass Malayi")
HeroBadge("Debug Build")
HeroBadge("Version: $versionName - $channelLabel")
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge("Build: $it")
}
}
if (latestUpdate != null) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["update_title"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = translation.format(
"update_content",
"version" to (latestUpdate.versionName)
),
color = Color.White.copy(alpha = 0.82f),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
AnimatedContent(
targetState = downloadState,
label = "UpdateDownloadHero"
) { state ->
when (state) {
UpdateDownloader.DownloadState.IDLE,
UpdateDownloader.DownloadState.FAILED -> {
Button(
onClick = onUpdateAction,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
contentPadding = PaddingValues(12.dp)
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = translation["download_icon_description"],
modifier = Modifier.size(18.dp)
)
}
}
UpdateDownloader.DownloadState.DOWNLOADING -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(end = 6.dp)
) {
CircularProgressIndicator(
progress = { downloadProgress },
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = Color.White
)
Text(
text = "${(downloadProgress * 100).toInt()}%",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
UpdateDownloader.DownloadState.COMPLETED -> {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = translation["completed_icon_description"],
tint = Color(0xFFA3F0C2)
)
Text(
text = "Ready to install",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}
Surface(
color = Color.White.copy(alpha = 0.08f),
shape = RoundedCornerShape(24.dp),
@@ -564,23 +705,6 @@ class HomeRootSection : Routes.Route() {
prefs.edit().remove("quick_tile_size_$key").apply()
}
private fun getTileOffset(name: String): Pair<Float, Float> {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
val raw = prefs.getString("quick_tile_offset_$key", null)
if (raw == null) return 0f to 0f
val parts = raw.split(',')
val x = parts.getOrNull(0)?.toFloatOrNull() ?: 0f
val y = parts.getOrNull(1)?.toFloatOrNull() ?: 0f
return x to y
}
private fun setTileOffset(name: String, x: Float, y: Float) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_offset_$key", "$x,$y").apply()
}
private fun clearTileOffset(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
@@ -611,11 +735,22 @@ class HomeRootSection : Routes.Route() {
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles().filter { it.isNotBlank() }
}
val latestUpdate by rememberAsyncMutableState(defaultValue = null) { Updater.latestRelease }
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") "Pre-release" else "Stable"
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
Updater.getLatestRelease(channel)
}
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val coroutineScope = rememberCoroutineScope()
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
var showChangelogDialog by remember { mutableStateOf(false) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
val handleUpdateAction: () -> Unit = {
latestUpdate?.let { latest ->
@@ -624,7 +759,7 @@ class HomeRootSection : Routes.Route() {
for (abi in supportedAbis) {
when (abi) {
"arm64-v8a" -> {
abiName = "armv8"
abiName = "arm64"
break
}
"armeabi-v7a" -> {
@@ -633,29 +768,79 @@ class HomeRootSection : Routes.Route() {
}
}
}
context.log.info(
"Update request: device ABIs=${supportedAbis.joinToString()} resolvedArch=${abiName ?: "unknown"}",
"HomeRoot"
)
if (latest.workflowId == null) {
context.androidContext.openLink(latest.releaseUrl)
} else if (abiName == null) {
android.widget.Toast.makeText(
context.androidContext,
"Your device architecture is not supported for automatic updates.",
android.widget.Toast.LENGTH_LONG
).show()
if (latest.workflowId != null) {
if (abiName == null) {
android.widget.Toast.makeText(
context.androidContext,
"Your device architecture is not supported for automatic updates.",
android.widget.Toast.LENGTH_LONG
).show()
} else {
val artifactName = "purrfectsnap-${abiName}-debug"
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
context.log.info("Debug update -> downloading $artifactName from $downloadUrl", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope)
}
return@let
}
val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] }
if (releaseDownload != null) {
val fileName = releaseDownload.substringAfterLast('/')
context.log.info("Release update -> arch=$abiName url=$releaseDownload file=$fileName", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope)
} else {
val artifactName = "purrfectsnap-${abiName}-debug"
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
UpdateDownloader.downloadAndInstall(context.androidContext, downloadUrl, "$artifactName.zip", coroutineScope)
context.log.warn(
"No matching update asset for arch=$abiName (available: ${latest.assetDownloads.keys})",
"HomeRoot"
)
context.androidContext.openLink(latest.releaseUrl)
}
}
}
fun loadChangelog(targetVersion: String, url: String) {
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
val body = response.body?.string() ?: throw IllegalStateException("Empty changelog body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
changelogError = error.message ?: "Failed to load changelog"
changelogLoading = false
}
}
}
}
val onUpdateButtonClick: () -> Unit = {
latestUpdate?.let {
showChangelogDialog = true
loadChangelog(it.versionName, changelogUrl)
}
}
var showQuickActionsMenu by remember { mutableStateOf(false) }
var editMode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val density = LocalDensity.current
val contentBottomPadding = routes.bottomPadding + navigationBarPadding + 96.dp
Box(
@@ -666,7 +851,7 @@ class HomeRootSection : Routes.Route() {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState, enabled = !editMode)
.verticalScroll(scrollState)
.padding(bottom = contentBottomPadding)
) {
Row(
@@ -680,18 +865,19 @@ class HomeRootSection : Routes.Route() {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(12.dp))
HeroSection(
versionName = BuildConfig.VERSION_NAME,
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = handleUpdateAction,
onUpdateAction = onUpdateButtonClick,
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onWikiClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/wiki") },
onTelegramClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
onGithubClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
authorName = "ΞTΞRNAL",
authorName = "ETERNAL",
onManageClick = { routes.settings.navigate() },
avenirNext = avenirNext,
)
@@ -723,7 +909,8 @@ class HomeRootSection : Routes.Route() {
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Column(
@@ -809,184 +996,27 @@ class HomeRootSection : Routes.Route() {
Spacer(modifier = Modifier.width(6.dp))
Text(text = "Manage")
}
Button(
onClick = { editMode = !editMode },
colors = ButtonDefaults.buttonColors(
containerColor = if (editMode) Color.White else Color.White.copy(alpha = 0.12f),
contentColor = if (editMode) Color(0xFF1B152E) else Color.White
)
) {
Icon(
imageVector = if (editMode) Icons.Default.Check else Icons.Filled.DragHandle,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(if (editMode) "Done" else "Reorder")
}
}
}
val spacing = 12.dp
var spanTick by remember { mutableIntStateOf(0) }
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val density = LocalDensity.current
val baseCell = remember { (maxWidth - (spacing * 2)) / 3f }
val baseCellPx = with(density) { baseCell.toPx() }
val (tilePositions, totalHeight) = remember(selectedTiles.size, spanTick) {
val positions = mutableMapOf<String, Offset>()
var currentX = 0f
var currentY = 0f
var rowMaxHeight = 0f
val screenWidthPx = with(density) { maxWidth.toPx() }
selectedTiles.forEach { tileName ->
cards.entries.find { entry -> entry.key.first == tileName }?.let {
val card = it.key
val (wSpan, hSpan) = getTileSpan(card.first)
val tileWidthPx = with(density) { (baseCell * wSpan + spacing * (wSpan - 1)).toPx() }
val tileHeightPx = with(density) { (baseCell * hSpan + spacing * (hSpan - 1)).toPx() }
if (currentX + tileWidthPx > screenWidthPx) {
currentX = 0f
currentY += rowMaxHeight
rowMaxHeight = 0f
}
positions[tileName] = Offset(currentX, currentY)
currentX += tileWidthPx + with(density) { spacing.toPx() }
if (tileHeightPx > rowMaxHeight) {
rowMaxHeight = tileHeightPx
}
}
}
positions to (currentY + rowMaxHeight)
}
Box(modifier = Modifier.height(with(density) { totalHeight.toDp() })) {
remember(selectedTiles.size, context.translation.loadedLocale) {
selectedTiles.mapNotNull {
cards.entries.find { entry -> entry.key.first == it }
}
}.forEach { (card, action) ->
val interactionSource = remember { MutableInteractionSource() }
val _tick = spanTick
val (wSpan, hSpan) = getTileSpan(card.first)
val tileWidth = baseCell * wSpan + spacing * (wSpan - 1)
val tileHeight = baseCell * hSpan + spacing * (hSpan - 1)
val tileWidthPx = with(density) { tileWidth.toPx() }
val tileHeightPx = with(density) { tileHeight.toPx() }
var offsetX by remember(card.first) { mutableStateOf(0f) }
var offsetY by remember(card.first) { mutableStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
LaunchedEffect(card.first, spanTick) {
val (x, y) = getTileOffset(card.first)
if (x != 0f || y != 0f) {
offsetX = x
offsetY = y
} else {
val pos = tilePositions[card.first]
if (pos != null) {
offsetX = pos.x
offsetY = pos.y
setTileOffset(card.first, offsetX, offsetY)
}
}
}
val animatedOffsetX by animateFloatAsState(
targetValue = offsetX,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "offsetX"
)
val animatedOffsetY by animateFloatAsState(
targetValue = offsetY,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "offsetY"
)
val currentOffsetX = if (isDragging) offsetX else animatedOffsetX
val currentOffsetY = if (isDragging) offsetY else animatedOffsetY
val baseModifier = Modifier
.offset { IntOffset(currentOffsetX.roundToInt(), currentOffsetY.roundToInt()) }
.width(tileWidth)
.height(tileHeight)
.padding(all = 6.dp)
val editModifier = baseModifier.then(
Modifier.pointerInput(card.first, tileWidthPx, tileHeightPx) {
var originalOffsetX = 0f
var originalOffsetY = 0f
detectDragGestures(
onDragStart = {
isDragging = true
originalOffsetX = offsetX
originalOffsetY = offsetY
},
onDrag = { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
var targetTile: String? = null
var maxOverlap = 0f
val tileRect = Rect(Offset(offsetX, offsetY), Size(tileWidthPx, tileHeightPx))
for (otherTileName in selectedTiles) {
if (otherTileName == card.first) continue
val (otherOffsetX, otherOffsetY) = getTileOffset(otherTileName)
val (otherWSpan, otherHSpan) = getTileSpan(otherTileName)
val otherTileWidth = baseCell * otherWSpan + spacing * (otherWSpan - 1)
val otherTileHeight = baseCell * otherHSpan + spacing * (otherHSpan - 1)
val otherRect = Rect(Offset(otherOffsetX, otherOffsetY), Size(with(density) { otherTileWidth.toPx() }, with(density) { otherTileHeight.toPx() }))
val intersectRect = tileRect.intersect(otherRect)
val overlapArea = intersectRect.width * intersectRect.height
if (overlapArea > maxOverlap) {
maxOverlap = overlapArea
targetTile = otherTileName
}
}
if (targetTile != null) {
val (wSpan, hSpan) = getTileSpan(card.first)
val (targetWSpan, targetHSpan) = getTileSpan(targetTile!!)
if (wSpan == targetWSpan && hSpan == targetHSpan) {
// swap
val (targetOffsetX, targetOffsetY) = getTileOffset(targetTile!!)
setTileOffset(card.first, targetOffsetX, targetOffsetY)
setTileOffset(targetTile!!, originalOffsetX, originalOffsetY)
spanTick++ // this will trigger recomposition for all tiles
} else {
// revert
offsetX = originalOffsetX
offsetY = originalOffsetY
}
} else {
setTileOffset(card.first, offsetX, offsetY)
}
}
)
}
)
val viewModifier = baseModifier.then(Modifier.scaleOnPress(interactionSource))
val tileContent: @Composable (Modifier, Boolean) -> Unit = { tileModifier, isEdit ->
val spacing = 12.dp
val gridPadding = 8.dp
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing),
contentPadding = PaddingValues(gridPadding)
) {
items(selectedTiles, key = { it }) { tileName ->
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@items
val (card, action) = cardEntry
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = tileModifier,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.05f)
.scaleOnPress(interactionSource)
.clickable { action(routes) },
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
@@ -1011,86 +1041,29 @@ class HomeRootSection : Routes.Route() {
.fillMaxSize()
.padding(all = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = Color.White,
modifier = Modifier.size(50.dp)
modifier = Modifier.size(44.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 14.sp,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
if (isEdit) {
var dxAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
var dyAccResize by remember(card.first, spanTick) { mutableStateOf(0f) }
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(28.dp)
.pointerInput(card.first, spanTick) {
detectDragGestures(
onDragStart = {
dxAccResize = 0f
dyAccResize = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dxAccResize += dragAmount.x
dyAccResize += dragAmount.y
var newW = wSpan
var newH = hSpan
val step = baseCellPx / 2f
while (dxAccResize > step) {
newW = (wSpan + 1).coerceIn(1, 3)
dxAccResize -= step
}
while (dxAccResize < -step) {
newW = (wSpan - 1).coerceIn(1, 3)
dxAccResize += step
}
while (dyAccResize > step) {
newH = (hSpan + 1).coerceIn(1, 3)
dyAccResize -= step
}
while (dyAccResize < -step) {
newH = (hSpan - 1).coerceIn(1, 3)
dyAccResize += step
}
if (newW != wSpan || newH != hSpan) {
setTileSpan(card.first, newW, newH)
spanTick++
selectedTiles.forEach { clearTileOffset(it) }
}
}
)
},
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.DragHandle, contentDescription = null, tint = Color.White)
}
}
}
}
}
if (editMode) {
tileContent(editModifier, true)
} else {
tileContent(viewModifier.then(Modifier.clickable { action(routes) }), false)
}
}
}
}
}
}
@@ -1098,6 +1071,71 @@ class HomeRootSection : Routes.Route() {
}
}
if (showChangelogDialog && latestUpdate != null) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = "Changelog",
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Update",
onConfirm = {
showChangelogDialog = false
handleUpdateAction()
},
dismissButtonText = "Cancel",
onDismiss = { showChangelogDialog = false },
confirmEnabled = !changelogLoading,
customContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 340.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
when {
changelogLoading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = Color.White
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading changelog…",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
changelogError != null -> {
Text(
text = changelogError ?: "Failed to load changelog",
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = changelogText ?: "Changelog not available",
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
@@ -1110,9 +1148,6 @@ class HomeRootSection : Routes.Route() {
newList.forEach { clearTileOffset(it) }
selectedTiles.clear()
selectedTiles.addAll(newList)
if (newList.isEmpty()) {
editMode = false
}
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}
@@ -1123,5 +1158,21 @@ class HomeRootSection : Routes.Route() {
}
}
}
private fun extractChangelogForVersion(raw: String, version: String): String {
val lines = raw.lines()
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
var collecting = false
val collected = mutableListOf<String>()
lines.forEach { line ->
if (headerRegex.containsMatchIn(line)) {
collecting = true
return@forEach
}
if (collecting && line.startsWith("#")) return@forEach
if (collecting) collected.add(line)
}
return collected.joinToString("\n").trim()
}
}

View File

@@ -4,6 +4,7 @@ import android.content.SharedPreferences
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
@@ -20,6 +21,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -68,8 +70,28 @@ class HomeSettings : Routes.Route() {
private fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
if (context.config.root.global.updateSettings.autoUpdateCheck.get()) {
val frequency = context.config.root.global.updateSettings.updateCheckFrequency.get()
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
@@ -86,6 +108,7 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
@@ -255,7 +278,7 @@ class HomeSettings : Routes.Route() {
) { content(this) }
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
@@ -291,6 +314,29 @@ class HomeSettings : Routes.Route() {
}
}
@Composable
fun AestheticDropdownField(
value: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.clip(shape)
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = value, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
@@ -374,29 +420,55 @@ class HomeSettings : Routes.Route() {
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"])
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
}
@@ -405,77 +477,115 @@ class HomeSettings : Routes.Route() {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedFrequency by remember { mutableStateOf(context.config.root.global.updateSettings.updateCheckFrequency.getNullable() ?: "weekly") }
var frequencyMenuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"])
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"])
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
selectedFrequency = "weekly"
context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
}
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
colors = purrfectSwitchColors()
)
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 26.dp)
ExposedDropdownMenuBox(
expanded = frequencyMenuExpanded,
onExpandedChange = { frequencyMenuExpanded = it },
expanded = channelMenuExpanded,
onExpandedChange = { channelMenuExpanded = it },
modifier = spacingModifier
) {
AestheticDropdownField(
value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel,
expanded = channelMenuExpanded,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 26.dp)
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
onClick = { channelMenuExpanded = true }
)
ExposedDropdownMenu(
expanded = channelMenuExpanded,
onDismissRequest = { channelMenuExpanded = false }
) {
TextField(
value = translation.getOrNull("update_check_frequency_${selectedFrequency}") ?: selectedFrequency,
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyMenuExpanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
listOf("stable", "prerelease").forEach { channel ->
DropdownMenuItem(
text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) },
onClick = {
selectedChannel = channel
channelMenuExpanded = false
context.config.root.global.updateSettings.updateChannel.set(channel)
context.config.writeConfig()
scheduleUpdateCheck()
}
)
)
ExposedDropdownMenu(
expanded = frequencyMenuExpanded,
onDismissRequest = { frequencyMenuExpanded = false }
) {
listOf("daily", "weekly", "monthly").forEach { frequency ->
DropdownMenuItem(
text = { Text(text = translation.getOrNull("update_check_frequency_${frequency}") ?: frequency) },
onClick = {
selectedFrequency = frequency
frequencyMenuExpanded = false
context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
context.config.writeConfig()
scheduleUpdateCheck()
}
)
}
}
}
}
}
}
}
GlassCard {
RowTitle(title = "Reset PurrfectSnap")
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
// Clear setup progress and route back to SetupActivity
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
// Clear config to defaults
context.config.reset()
context.config.writeConfig()
// Launch setup activity fresh
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
// Close current manager activity
routes.navController.popBackStack()
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Reset and restart setup",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Reset",
modifier = Modifier.padding(end = 14.dp)
)
}
}
@@ -488,6 +598,7 @@ class HomeSettings : Routes.Route() {
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredStoriesCount()
}
var showImportDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -507,12 +618,12 @@ class HomeSettings : Routes.Route() {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row(
FlowRow(
modifier = Modifier
.wrapContentWidth()
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
@@ -576,6 +687,13 @@ class HomeSettings : Routes.Route() {
) {
Text(text = translation["clear_button"])
}
Button(
onClick = { showImportDialog = true },
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = "Import")
}
}
}
OutlinedButton(
@@ -588,6 +706,46 @@ class HomeSettings : Routes.Route() {
) {
Text(translation["view_logger_history_button"])
}
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import message logger") },
text = { Text("Importing will override your current message logger database. Continue?") },
confirmButton = {
TextButton(onClick = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
runCatching {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
context.messageLogger.databaseFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Unable to open selected file")
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
context.shortToast(translation["success_toast"])
context.log.info("Imported message logger from $uri", "MessageLogger")
}.onFailure {
context.log.error("Failed to import message logger", it)
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
}
}
}.onFailure {
context.log.error("Failed to launch import picker", it)
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
}
}) {
Text(translation["button.import"] ?: "Import")
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text(translation["button.cancel"])
}
}
)
}
}
}
@@ -665,31 +823,23 @@ class HomeSettings : Routes.Route() {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)
) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
Box(
modifier = Modifier
.weight(1f)
.padding(start = 26.dp)
) {
Box(modifier = Modifier.weight(1f)) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
TextField(
AestheticDropdownField(
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
onValueChange = {},
readOnly = true,
expanded = expanded,
modifier = Modifier
.fillMaxWidth(),
colors = ExposedDropdownMenuDefaults.textFieldColors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
onClick = { expanded = true }
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType ->

View File

@@ -0,0 +1,339 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.view.MotionEvent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.random.Random
class RetroGameScreen : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val gridWidth = 120
val gridHeight = 160
val shipSpeed = 1.7f
val shipRadius = 4.5f
val maxSpeedMultiplier = 2.2f
val speedRampPerScore = 0.035f
val rng = remember { Random(System.currentTimeMillis()) }
val pixelFont = remember { FontFamily.Monospace }
data class Star(var x: Float, var y: Float, var speed: Float)
data class Planet(var x: Float, var y: Float, var radius: Float, var speed: Float)
var score by remember { mutableIntStateOf(0) }
var isGameOver by remember { mutableStateOf(false) }
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
var shipX by remember { mutableFloatStateOf(gridWidth / 2f) }
val shipY = gridHeight - 20f
val stars = remember { mutableListOf<Star>() }
val planets = remember { mutableListOf<Planet>() }
val frameBitmap = remember { ImageBitmap(gridWidth, gridHeight) }
val frameCanvas = remember { Canvas(frameBitmap) }
val paint = remember { Paint() }
var frameTick by remember { mutableIntStateOf(0) }
fun spawnPlanet(): Planet {
val radius = rng.nextInt(6, 14).toFloat()
val x = rng.nextInt(radius.roundToInt(), gridWidth - radius.roundToInt()).toFloat()
val y = rng.nextInt(-gridHeight, -20).toFloat()
val speed = rng.nextInt(10, 20) / 10f
return Planet(x = x, y = y, radius = radius, speed = speed)
}
fun resetGame() {
score = 0
isGameOver = false
shipX = gridWidth / 2f
stars.clear()
planets.clear()
repeat(28) {
stars.add(
Star(
x = rng.nextInt(0, gridWidth).toFloat(),
y = rng.nextInt(0, gridHeight).toFloat(),
speed = rng.nextInt(6, 12) / 10f
)
)
}
repeat(4) { planets.add(spawnPlanet()) }
}
fun drawRect(left: Float, top: Float, right: Float, bottom: Float, color: Color) {
paint.color = color
paint.alpha = 1f
frameCanvas.drawRect(Rect(left, top, right, bottom), paint)
}
fun drawCircle(centerX: Float, centerY: Float, radius: Float, color: Color) {
paint.color = color
frameCanvas.drawCircle(androidx.compose.ui.geometry.Offset(centerX, centerY), radius, paint)
}
fun drawShip() {
val sx = shipX.roundToInt().toFloat()
drawRect(sx - 2f, shipY, sx + 3f, shipY + 1f, Color(0xFFE7F6FF))
drawRect(sx - 3f, shipY + 1f, sx + 4f, shipY + 3f, Color(0xFFE7F6FF))
drawRect(sx - 4f, shipY + 3f, sx + 5f, shipY + 6f, Color(0xFFE7F6FF))
drawRect(sx - 2f, shipY + 6f, sx + 3f, shipY + 8f, Color(0xFFE7F6FF))
drawRect(sx - 1f, shipY + 2f, sx + 2f, shipY + 4f, Color(0xFF8AF4FF))
if (!isGameOver) {
drawRect(sx - 1f, shipY + 8f, sx + 2f, shipY + 10f, Color(0xFFFFB24D))
drawRect(sx, shipY + 10f, sx + 1f, shipY + 12f, Color(0xFFFF6B6B))
}
}
fun renderFrame() {
drawRect(0f, 0f, gridWidth.toFloat(), gridHeight.toFloat(), Color(0xFF1B1440))
drawRect(0f, 0f, gridWidth.toFloat(), 14f, Color(0xFF5236B8))
stars.forEach { star ->
drawRect(star.x, star.y, star.x + 1f, star.y + 1f, Color(0xFFEEE9FF))
}
planets.forEach { planet ->
drawCircle(planet.x, planet.y, planet.radius, Color(0xFF4FD4FF))
drawCircle(planet.x - planet.radius * 0.3f, planet.y - planet.radius * 0.2f, planet.radius * 0.35f, Color(0xFFB0F0FF))
}
drawShip()
if (isGameOver) {
drawRect(0f, gridHeight / 2f - 10f, gridWidth.toFloat(), gridHeight / 2f + 8f, Color(0xAA000000))
}
}
LaunchedEffect(Unit) {
resetGame()
while (true) {
delay(16)
if (!isGameOver) {
val speedMultiplier = min(
maxSpeedMultiplier,
1f + (score * speedRampPerScore)
)
val direction = (if (leftPressed) -1f else 0f) + (if (rightPressed) 1f else 0f)
shipX = (shipX + direction * shipSpeed).coerceIn(8f, gridWidth - 8f)
stars.forEach { star ->
star.y += star.speed * speedMultiplier
if (star.y > gridHeight) {
star.y = 0f
star.x = rng.nextInt(0, gridWidth).toFloat()
}
}
planets.forEachIndexed { index, planet ->
planet.y += planet.speed * speedMultiplier
if (planet.y - planet.radius > gridHeight) {
planets[index] = spawnPlanet()
score += 1
}
}
planets.forEach { planet ->
val dx = abs(planet.x - shipX)
val dy = abs(planet.y - shipY)
if (dx * dx + dy * dy < (planet.radius + shipRadius) * (planet.radius + shipRadius)) {
isGameOver = true
}
}
}
renderFrame()
frameTick++
}
}
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = "Retro Flight",
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth()
.weight(1f, fill = true)
.aspectRatio(3f / 4f, matchHeightConstraintsFirst = true)
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(14.dp))
.background(Color(0xFF21195A), RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
key(frameTick) {
Image(
bitmap = frameBitmap,
contentDescription = null,
filterQuality = FilterQuality.None,
modifier = Modifier
.fillMaxSize()
.border(1.dp, Color.White.copy(alpha = 0.1f))
)
}
Text(
text = score.toString(),
color = Color.White,
fontSize = 16.sp,
fontFamily = pixelFont,
modifier = Modifier.align(Alignment.TopCenter).padding(top = 6.dp)
)
if (isGameOver) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "GAME OVER",
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = pixelFont
)
Spacer(modifier = Modifier.height(6.dp))
Button(
onClick = { resetGame() },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF5A43D6),
contentColor = Color.White
),
shape = RoundedCornerShape(6.dp)
) {
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
}
}
}
}
Row(
modifier = Modifier
.padding(horizontal = 18.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
modifier = Modifier
.weight(1f)
.height(56.dp)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
leftPressed = true
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
leftPressed = false
true
}
else -> false
}
},
shape = RoundedCornerShape(10.dp),
color = Color(0xFF2E2568),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "LEFT",
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
textAlign = TextAlign.Center
)
}
}
Surface(
modifier = Modifier
.weight(1f)
.height(56.dp)
.pointerInteropFilter {
when (it.action) {
MotionEvent.ACTION_DOWN -> {
rightPressed = true
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
rightPressed = false
true
}
else -> false
}
},
shape = RoundedCornerShape(10.dp),
color = Color(0xFF2E2568),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "RIGHT",
color = Color.White,
fontSize = 14.sp,
fontFamily = pixelFont,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -109,7 +110,14 @@ class ManageScriptReposSection : Routes.Route() {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
contentColor = Color.White,
shape = RoundedCornerShape(18.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
focusedElevation = 0.dp,
hoveredElevation = 0.dp
)
) {
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))

View File

@@ -27,7 +27,6 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -45,7 +44,7 @@ class AddFriendDialog(
val getGroupState: (group: MessagingGroupInfo) -> Boolean,
)
private val stateCache = mutableMapOf<String, Boolean>()
private val stateCache = mutableStateMapOf<String, Boolean>()
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
@Composable
@@ -57,9 +56,13 @@ class AddFriendDialog(
getCurrentState: () -> Boolean,
onState: (Boolean) -> Unit = {},
) {
var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) {
getCurrentState().also { stateCache[id] = it }
val cachedState = stateCache[id]
LaunchedEffect(id, cachedState) {
if (cachedState == null) {
stateCache[id] = getCurrentState()
}
}
val currentState = stateCache[id] ?: false
val coroutineScope = rememberCoroutineScope()
val cardShape = RoundedCornerShape(18.dp)
@@ -68,9 +71,9 @@ class AddFriendDialog(
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable {
currentState = !currentState
stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
val nextState = !currentState
stateCache[id] = nextState
coroutineScope.launch(Dispatchers.IO) { onState(nextState) }
},
shape = cardShape,
color = Color.Transparent,
@@ -125,9 +128,8 @@ class AddFriendDialog(
Switch(
checked = currentState,
onCheckedChange = {
currentState = it
stateCache[id] = currentState
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
stateCache[id] = it
coroutineScope.launch(Dispatchers.IO) { onState(it) }
},
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
@@ -156,19 +158,12 @@ class AddFriendDialog(
)
.padding(horizontal = 16.dp, vertical = 18.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = translation["title"],
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Text(
text = translation["search_hint"],
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.85f)
)
}
Text(
text = translation["title"],
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
}
Surface(
@@ -185,10 +180,11 @@ class AddFriendDialog(
value = searchKeyword.value,
onValueChange = { searchKeyword.value = it },
placeholder = {
Text(text = translation["search_hint"])
Text(text = translation["search_hint"], color = PurrfectPalette.textSecondary)
},
modifier = Modifier
.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = translation["search_icon_description"])
@@ -198,8 +194,15 @@ class AddFriendDialog(
unfocusedContainerColor = PurrfectPalette.cardOverlayColor.copy(alpha = 0.8f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White
)
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.85f),
focusedPlaceholderColor = PurrfectPalette.textSecondary,
unfocusedPlaceholderColor = PurrfectPalette.textSecondary
),
textStyle = LocalTextStyle.current.copy(color = Color.White)
)
}
}
@@ -302,6 +305,15 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
stateCache[friend.userId] ?: actionHandler.getFriendState(friend)
}
}
}
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
DialogHeader(searchKeyword)
@@ -337,14 +349,63 @@ class AddFriendDialog(
item {
if (filteredFriends.isNotEmpty()) {
Text(
text = translation["category_friends"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 14.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_friends"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredFriends.forEach { friend ->
stateCache[friend.userId] = true
actionHandler.onFriendState(friend, true)
}
}
},
enabled = !allFriendsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
?: "Select All",
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredFriends.forEach { friend ->
stateCache[friend.userId] = false
actionHandler.onFriendState(friend, false)
}
}
},
enabled = hasFriendsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
)
}
}
}
}
}

View File

@@ -4,18 +4,23 @@ import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.rounded.DeleteForever
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
@@ -46,6 +51,7 @@ import me.eternal.purrfectsnap.ui.util.AlertDialogs
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -72,7 +78,7 @@ class ManageScope: Routes.Route() {
var deleteConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf(id, scope)) {
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf<Any>(id, scope)) {
when (scope) {
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
@@ -304,6 +310,61 @@ class ManageScope: Routes.Route() {
)
}
@Composable
private fun RowScope.E2eeActionButton(
label: String,
icon: ImageVector,
accent: Brush,
onClick: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.weight(1f)
.scaleOnPress(interactionSource)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = interactionSource,
indication = null
) { onClick() },
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(accent, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Text(
text = label,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
private fun computeStreakETA(timestamp: Long): String? {
val now = System.currentTimeMillis()
val stringBuilder = StringBuilder()
@@ -344,7 +405,6 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
if (context.config.root.experimental.e2eEncryption.globalState == true) {
SectionTitle(translation["e2ee_title"])
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
context.e2eeImplementation.friendKeyExists(friend.userId)
}
@@ -378,40 +438,100 @@ class ManageScope: Routes.Route() {
}
ContentCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
),
RoundedCornerShape(14.dp)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = Color.White
)
}
}) {
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
Text(
text = translation["export_base64_button"],
maxLines = 1
text = translation["e2ee_title"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 16.sp
)
Text(
text = translation["e2ee_subtitle"],
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (hasSecretKey) {
E2eeActionButton(
label = translation["export_base64_button"],
icon = Icons.Filled.Lock,
accent = Brush.horizontalGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.55f)
)
),
onClick = {
context.coroutineScope.launch {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}
}
)
}
OutlinedButton(onClick = { importDialog = true }) {
Text(
text = translation["import_base64_button"],
maxLines = 1
E2eeActionButton(
label = translation["import_base64_button"],
icon = Icons.Filled.Lock,
accent = Brush.horizontalGradient(
listOf(
Color(0xFF7DD3FC),
Color(0xFF818CF8)
)
),
onClick = { importDialog = true }
)
}
}

View File

@@ -10,18 +10,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
@@ -54,8 +59,16 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun ScopeList(scope: SocialScope) {
private fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
val list = when (scope) {
SocialScope.GROUP -> groups
SocialScope.FRIEND -> friends
}
LazyColumn(
modifier = Modifier
@@ -64,10 +77,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
//check if scope list is empty
val listSize = when (scope) {
SocialScope.GROUP -> groupList.size
SocialScope.FRIEND -> friendList.size
}
val listSize = list.size
if (listSize == 0) {
item {
@@ -76,14 +86,14 @@ class SocialRootSection : Routes.Route() {
}
items(listSize) { index ->
val id = when (scope) {
SocialScope.GROUP -> groupList[index].conversationId
SocialScope.FRIEND -> friendList[index].userId
}
val friend = if (scope == SocialScope.FRIEND) list[index] as MessagingFriendInfo else null
val group = if (scope == SocialScope.GROUP) list[index] as MessagingGroupInfo else null
val id = friend?.userId ?: group?.conversationId.orEmpty()
SocialCard(
scope = scope,
index = index,
friend = friend,
group = group,
onManage = {
routes.manageScope.navigate {
put("id", id)
@@ -182,10 +192,30 @@ class SocialRootSection : Routes.Route() {
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList
} else {
friendList.filter {
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
val filteredGroups = remember(groupList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
groupList
} else {
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
}
}
Column(
modifier = Modifier
@@ -199,8 +229,78 @@ class SocialRootSection : Routes.Route() {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
friendCount = friendList.size,
groupCount = groupList.size
groupCount = groupList.size,
searchActive = searchActive,
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = searchShape,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, searchBorder),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, searchShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = searchHint,
tint = PurrfectPalette.textSecondary
)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontSize = 15.sp
),
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
modifier = Modifier.weight(1f)
) { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = searchHint,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
innerTextField()
}
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = context.translation["close_button_description"]
?: "Clear search",
tint = Color.White
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
@@ -208,8 +308,8 @@ class SocialRootSection : Routes.Route() {
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND)
1 -> ScopeList(SocialScope.GROUP)
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
}
}
@@ -218,7 +318,8 @@ class SocialRootSection : Routes.Route() {
@Composable
private fun SocialCard(
scope: SocialScope,
index: Int,
friend: MessagingFriendInfo?,
group: MessagingGroupInfo?,
onManage: () -> Unit,
onPreview: () -> Unit,
remainingHours: Int
@@ -249,7 +350,7 @@ class SocialRootSection : Routes.Route() {
) {
when (scope) {
SocialScope.GROUP -> {
val group = groupList[index]
val groupInfo = group ?: return@Row
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.08f),
@@ -267,7 +368,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = group.name,
text = groupInfo.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
@@ -283,16 +384,16 @@ class SocialRootSection : Routes.Route() {
}
SocialScope.FRIEND -> {
val friend = friendList[index]
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
context.database.getFriendStreaks(friend.userId)
val friendInfo = friend ?: return@Row
val streaks by rememberAsyncMutableState(defaultValue = friendInfo.streaks) {
context.database.getFriendStreaks(friendInfo.userId)
}
BitmojiImage(
context = context,
url = BitmojiSelfie.getBitmojiSelfie(
friend.selfieId,
friend.bitmojiId,
friendInfo.selfieId,
friendInfo.bitmojiId,
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
)
@@ -302,7 +403,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = friend.displayName ?: friend.mutableUsername,
text = friendInfo.displayName ?: friendInfo.mutableUsername,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold,
@@ -310,7 +411,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = friend.mutableUsername,
text = friendInfo.mutableUsername,
maxLines = 1,
fontSize = 12.sp,
fontWeight = FontWeight.Light,
@@ -383,7 +484,9 @@ class SocialRootSection : Routes.Route() {
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit,
friendCount: Int,
groupCount: Int
groupCount: Int,
searchActive: Boolean,
onSearchToggle: () -> Unit
) {
Surface(
modifier = Modifier
@@ -431,6 +534,13 @@ class SocialRootSection : Routes.Route() {
) {
StatPill(label = "Friends", value = friendCount)
StatPill(label = "Groups", value = groupCount)
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) "Close search" else "Search",
tint = Color.White
)
}
}
}
SocialTabSwitcher(

View File

@@ -54,6 +54,14 @@ import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
class EditRule : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
private data class RuleSnapshot(
val name: String,
val author: String,
val scopes: List<String>,
val events: List<TrackerRuleEvent>,
val scopeType: TrackerScopeType
)
@Composable
private fun RuleCard(
modifier: Modifier = Modifier,
@@ -174,11 +182,11 @@ class EditRule : Routes.Route() {
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth()
) {
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth()
) {
val eventLabel = context.translation["tracker_events.${currentEventType.value}"]
Box(
modifier = Modifier.fillMaxWidth(),
@@ -186,8 +194,8 @@ class EditRule : Routes.Route() {
) {
OutlinedTextField(
modifier = Modifier
.menuAnchor()
.widthIn(min = 240.dp),
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.wrapContentWidth(),
value = eventLabel,
onValueChange = {},
readOnly = true,
@@ -207,7 +215,7 @@ class EditRule : Routes.Route() {
ExposedDropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false },
modifier = Modifier.widthIn(min = 240.dp),
modifier = Modifier.wrapContentWidth(),
containerColor = Color(0xFF121528),
shape = RoundedCornerShape(14.dp)
) {
@@ -295,24 +303,45 @@ class EditRule : Routes.Route() {
val authorName = rememberAsyncMutableState<String>(defaultValue = "", keys = arrayOf(currentRuleId)) {
currentRuleId?.let { ruleId -> context.database.getTrackerRule(ruleId)?.author ?: "" } ?: ""
}
val initialRuleState by remember(ruleName.value.isNotBlank() || events.isNotEmpty() || scopes.isNotEmpty()) {
mutableStateOf(
mapOf(
"name" to ruleName.value,
"author" to authorName.value,
"scopes" to scopes.toList(),
"events" to events.toList(),
"scopeType" to currentScopeType
)
)
fun snapshotEvents() = events.map { event ->
event.copy(params = event.params.copy(), actions = event.actions.toList())
}
fun buildSnapshot() = RuleSnapshot(
name = ruleName.value,
author = authorName.value,
scopes = scopes.toList(),
events = snapshotEvents(),
scopeType = currentScopeType
)
val initialSnapshot = remember(currentRuleId) {
mutableStateOf<RuleSnapshot?>(if (currentRuleId == null) buildSnapshot() else null)
}
LaunchedEffect(
currentRuleId,
ruleName.value,
authorName.value,
events.size,
scopes.size,
currentScopeType
) {
if (initialSnapshot.value == null) {
val hasLoaded = ruleName.value.isNotBlank() ||
authorName.value.isNotBlank() ||
events.isNotEmpty() ||
scopes.isNotEmpty()
if (hasLoaded) {
initialSnapshot.value = buildSnapshot()
}
}
}
val isDirty by remember {
derivedStateOf {
initialRuleState["name"] != ruleName.value ||
initialRuleState["author"] != authorName.value ||
initialRuleState["scopes"] != scopes.toList() ||
initialRuleState["events"] != events.toList() ||
initialRuleState["scopeType"] != currentScopeType
val snapshot = initialSnapshot.value ?: return@derivedStateOf false
snapshot.name != ruleName.value ||
snapshot.author != authorName.value ||
snapshot.scopes != scopes.toList() ||
snapshot.events != snapshotEvents() ||
snapshot.scopeType != currentScopeType
}
}
var deleteConfirmation by remember { mutableStateOf(false) }
@@ -426,6 +455,12 @@ class EditRule : Routes.Route() {
fontSize = 12.sp
)
}
if (currentRuleId != null) {
IconButton(onClick = { deleteConfirmation = true }) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
}
Spacer(modifier = Modifier.width(6.dp))
}
IconButton(onClick = {
if (events.isEmpty()) {
showEventsEmptyDialog = true
@@ -451,11 +486,6 @@ class EditRule : Routes.Route() {
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
routes.navController.popBackStack()
}) { Icon(Icons.Filled.Save, contentDescription = translation["save_button_description"], tint = Color.White) }
if (currentRuleId != null) {
IconButton(onClick = { deleteConfirmation = true }) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
}
}
}
}
}
@@ -466,9 +496,11 @@ class EditRule : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.padding(padding)
) {
val contentBottomPadding = routes.bottomPadding + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = contentBottomPadding)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -477,6 +509,7 @@ class EditRule : Routes.Route() {
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
val textFieldShape = RoundedCornerShape(16.dp)
Text(
translation["general_section_title"],
style = MaterialTheme.typography.titleMedium,
@@ -488,8 +521,11 @@ class EditRule : Routes.Route() {
value = ruleName.value,
onValueChange = { ruleName.value = it },
label = { Text(translation["rule_name_label"], color = PurrfectPalette.textSecondary) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp),
singleLine = true,
shape = textFieldShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.05f),
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
@@ -506,8 +542,11 @@ class EditRule : Routes.Route() {
value = authorName.value,
onValueChange = { authorName.value = it },
label = { Text(translation["author_name_label"], color = PurrfectPalette.textSecondary) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp),
singleLine = true,
shape = textFieldShape,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.05f),
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
@@ -640,12 +679,21 @@ class EditRule : Routes.Route() {
shape = selectorShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.38f)
)
)
)
) {
Row(
modifier = Modifier
.background(selectorBrush, selectorShape)
.clip(selectorShape)
.background(selectorBrush)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -773,7 +821,6 @@ class EditRule : Routes.Route() {
}
}
}
Spacer(Modifier.height(routes.bottomPadding))
}
}
}

View File

@@ -11,26 +11,24 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.ui.unit.DpOffset
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import java.util.Date
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.window.PopupProperties
@@ -328,36 +326,50 @@ fun LogsTab(
var dropDownExpanded by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
LaunchedEffect(selectionExpanded.value) {
if (!selectionExpanded.value) {
dropDownExpanded = false
}
}
LaunchedEffect(showDatePicker) {
if (showDatePicker) {
sinceDatePickerState.displayMode = DisplayMode.Picker
}
}
if (showDatePicker) {
Dialog(onDismissRequest = { showDatePicker = false }) {
val dialogShape = RoundedCornerShape(22.dp)
Surface(
shape = dialogShape,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
shadowElevation = 16.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, dialogShape)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
AestheticDialog(
onDismissRequest = { showDatePicker = false },
title = "",
text = "",
icon = Icons.Default.DateRange,
confirmButtonText = context.translation["button.ok"],
onConfirm = { showDatePicker = false },
dismissButtonText = context.translation["button.cancel"],
onDismiss = {
showDatePicker = false
sinceDatePickerState.selectedDateMillis = null
},
customContent = {
Surface(
modifier = Modifier.padding(horizontal = 6.dp),
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
translation["pick_a_date_button"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
DatePicker(
state = sinceDatePickerState,
modifier = Modifier
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.02f), RoundedCornerShape(18.dp)),
.widthIn(max = 360.dp)
.padding(6.dp)
.background(PurrfectPalette.cardOverlayColor, RoundedCornerShape(14.dp)),
title = null,
headline = null,
showModeToggle = false,
colors = DatePickerDefaults.colors(
containerColor = Color.Transparent,
containerColor = PurrfectPalette.cardOverlayColor,
titleContentColor = Color.White,
headlineContentColor = Color.White,
weekdayContentColor = Color.White.copy(alpha = 0.9f),
@@ -368,45 +380,35 @@ fun LogsTab(
todayDateBorderColor = PurrfectPalette.glowSecondary,
dayContentColor = Color.White.copy(alpha = 0.85f),
disabledDayContentColor = Color.White.copy(alpha = 0.35f),
yearContentColor = Color.White,
currentYearContentColor = PurrfectPalette.glowSecondary,
selectedYearContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
selectedYearContentColor = Color.Black,
dividerColor = Color.White.copy(alpha = 0.14f),
navigationContentColor = Color.White
navigationContentColor = Color.White,
dateTextFieldColors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.6f),
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
disabledContainerColor = Color.White.copy(alpha = 0.04f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
cursorColor = PurrfectPalette.glowSecondary,
focusedLabelColor = PurrfectPalette.textSecondary,
unfocusedLabelColor = PurrfectPalette.textSecondary
)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Surface(
onClick = {
showDatePicker = false
sinceDatePickerState.selectedDateMillis = null
},
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
context.translation["button.cancel"],
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
Surface(
onClick = { showDatePicker = false },
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Text(
context.translation["button.ok"],
color = Color.White,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
}
}
},
opaque = true,
showCloseButton = false,
showIcon = false,
showTitle = false
)
}
DropdownMenu(
@@ -443,27 +445,33 @@ fun LogsTab(
ExposedDropdownMenuBox(
expanded = dropDownExpanded,
onExpandedChange = { dropDownExpanded = it },
modifier = Modifier.weight(1f)
modifier = Modifier.wrapContentWidth()
) {
Surface(
onClick = { dropDownExpanded = true },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)),
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f)
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp
) {
Text(
filterType.name,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
color = Color.White
)
Row(
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(filterType.name, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropDownExpanded)
}
}
ExposedDropdownMenu(
expanded = dropDownExpanded,
onDismissRequest = { dropDownExpanded = false },
containerColor = Color(0xFF101220),
shape = RoundedCornerShape(14.dp)
shape = RoundedCornerShape(14.dp),
modifier = Modifier.wrapContentWidth()
) {
FriendTrackerManagerRoot.FilterType.entries.forEach { type ->
DropdownMenuItem(
@@ -489,7 +497,6 @@ fun LogsTab(
checked = reverseSortOrder,
onCheckedChange = {
reverseSortOrder = it
selectionExpanded.value = false
},
colors = purrfectSwitchColors()
)

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -99,7 +100,14 @@ class ManageFriendTrackerReposSection: Routes.Route() {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
contentColor = Color.White,
shape = RoundedCornerShape(18.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
focusedElevation = 0.dp,
hoveredElevation = 0.dp
)
) {
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))

View File

@@ -56,12 +56,14 @@ import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -89,6 +91,7 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.InstallModeScreen
@@ -97,8 +100,10 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.MappingsScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PermissionsScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
private data class SetupStepMeta(
val route: String,
@@ -125,14 +130,19 @@ class SetupActivity : ComponentActivity() {
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
val persistedRoute = setupPrefs.getString("setup_current_route", null)
val persistedSkipPatch = setupPrefs.getBoolean("setup_skip_patch", false)
val persistedInstallMode = setupPrefs.getString("setup_install_mode", null)
val skipPatchChoice = mutableStateOf(persistedSkipPatch)
val installModeChoice = mutableStateOf(
runCatching { persistedInstallMode?.let { InstallMode.valueOf(it) } }.getOrNull()
)
fun persistProgress(route: String, skipPatch: Boolean, inProgress: Boolean = true) {
fun persistProgress(route: String, skipPatch: Boolean, installMode: InstallMode?, inProgress: Boolean = true) {
if (!isFirstRunFlow) return
setupPrefs.edit()
.putBoolean("setup_in_progress", inProgress)
.putString("setup_current_route", route)
.putBoolean("setup_skip_patch", skipPatch)
.putString("setup_install_mode", installMode?.name)
.apply()
}
@@ -141,6 +151,7 @@ class SetupActivity : ComponentActivity() {
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
}
@@ -148,11 +159,19 @@ class SetupActivity : ComponentActivity() {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen { mode ->
skipPatchChoice.value = mode == InstallMode.ROOT
}.apply { route = "installMode" })
add(InstallModeScreen(
onModeChosen = { mode ->
installModeChoice.value = mode
skipPatchChoice.value = false
},
onSkipAutoSetup = {
skipPatchChoice.value = true
installModeChoice.value = null
}
).apply { route = "installMode" })
}
if (isFirstRunFlow) {
add(RootInstallSnapchatScreen().apply { route = "rootInstallSnapchat" })
add(PatchSnapchatScreen().apply { route = "patchSnapchat" })
}
}
@@ -173,31 +192,46 @@ class SetupActivity : ComponentActivity() {
}
requiredScreens.forEach { screen ->
screen.context = setupContext
screen.isFirstRunFlow = isFirstRunFlow
screen.init()
}
if (!isFirstRunFlow) {
clearProgress()
skipPatchChoice.value = false
}
if (!isFirstRunFlow) {
clearProgress()
skipPatchChoice.value = false
installModeChoice.value = null
}
setContent {
val navController = rememberNavController()
var canGoNext by remember { mutableStateOf(false) }
var lastRoute by rememberSaveable { mutableStateOf("") }
var currentRoute by rememberSaveable {
mutableStateOf(
when {
requiredScreens.first().route == "language" -> requiredScreens.first().route
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } } != null -> persistedRoute
else -> requiredScreens.first().route
}
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } }
?: requiredScreens.first().route
)
}
val skipPatch by rememberSaveable { skipPatchChoice }
val visibleScreens = remember(skipPatch) {
requiredScreens.filterNot { skipPatch && it is PatchSnapchatScreen }
val installMode by installModeChoice
val visibleScreens = remember(skipPatch, installMode) {
requiredScreens.filterNot { screen ->
if (skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
return@filterNot true
}
if (installMode == null && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
return@filterNot true
}
if (installMode == InstallMode.ROOT && screen is PatchSnapchatScreen) {
return@filterNot true
}
if (installMode == InstallMode.NON_ROOT && screen is RootInstallSnapchatScreen) {
return@filterNot true
}
false
}
}
val stepMeta = remember(skipPatch) { visibleScreens.map { it.meta(setupContext) } }
val stepMeta = remember(skipPatch, installMode) { visibleScreens.map { it.meta(setupContext) } }
val currentStepIndex = visibleScreens.indexOfFirst { it.route == currentRoute }.let {
if (it == -1) 0 else it
}
@@ -218,15 +252,18 @@ class SetupActivity : ComponentActivity() {
}
}
LaunchedEffect(currentRoute, skipPatch) {
persistProgress(currentRoute, skipPatch, true)
LaunchedEffect(currentRoute, skipPatch, installMode) {
persistProgress(currentRoute, skipPatch, installMode, true)
if (navController.currentDestination?.route != currentRoute) {
navController.navigate(currentRoute) {
popUpTo(requiredScreens.first().route) { inclusive = false }
launchSingleTop = true
}
}
canGoNext = false
if (lastRoute != currentRoute) {
canGoNext = false
lastRoute = currentRoute
}
}
fun nextScreen() {
@@ -249,10 +286,25 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@Suppress("DEPRECATION")
window.statusBarColor = Color.Transparent.toArgb()
@Suppress("DEPRECATION")
window.navigationBarColor = Color.Transparent.toArgb()
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.isAppearanceLightStatusBars = false
@@ -263,6 +315,37 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) "I understand (${importantTimeout}s)" else "I understand"
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = "Important!",
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = "If you have used SnapEnhance or any other mod besides PurrfectSnap, we recommend uninstalling everything and staying on stock Snapchat for one week. Then switch to PurrfectSnap after next Friday.",
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
val bottomPadding = 118.dp + navBarPadding
@@ -386,6 +469,13 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
icon = Icons.Filled.Download
)
is RootInstallSnapchatScreen -> SetupStepMeta(
route = route,
title = "Snapchat Installer",
subtitle = "Download and install the recommended Snapchat build.",
icon = Icons.Filled.Download
)
is SaveFolderScreen -> SetupStepMeta(
route = route,
title = translation["setup.dialogs.save_folder"] ?: "Where should we save?",

View File

@@ -28,6 +28,7 @@ abstract class SetupScreen {
lateinit var allowNext: (canGoNext: Boolean) -> Unit
lateinit var goNext: () -> Unit
lateinit var route: String
var isFirstRunFlow: Boolean = false
@Composable
fun DialogText(text: String, modifier: Modifier = Modifier) {

View File

@@ -9,19 +9,26 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -32,9 +39,16 @@ 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.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.interaction.MutableInteractionSource
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
@@ -42,25 +56,140 @@ import me.eternal.purrfectsnap.ui.util.scaleOnPress
enum class InstallMode { ROOT, NON_ROOT }
class InstallModeScreen(
private val onModeChosen: (InstallMode) -> Unit
private val onModeChosen: (InstallMode) -> Unit,
private val onSkipAutoSetup: () -> Unit
) : SetupScreen() {
private var selectedMode: InstallMode? = null
private var skipAutoSetup = false
override fun init() {
selectedMode = null
skipAutoSetup = false
}
override fun onLeave() {
selectedMode?.let(onModeChosen)
if (skipAutoSetup) return
}
@Composable
override fun Content() {
var choice by remember { mutableStateOf(selectedMode) }
var skipSelected by remember { mutableStateOf(skipAutoSetup) }
var showGuides by remember { mutableStateOf(true) }
var timeout by remember { mutableIntStateOf(15) }
LaunchedEffect(choice) {
LaunchedEffect(choice, skipSelected) {
selectedMode = choice
allowNext(choice != null)
skipAutoSetup = skipSelected
allowNext(choice != null || skipSelected)
if (!skipSelected) {
choice?.let(onModeChosen)
}
}
LaunchedEffect(showGuides) {
if (showGuides) {
timeout = 15
while (timeout > 0) {
delay(1000)
timeout--
}
}
}
if (showGuides) {
val confirmLabel = if (timeout > 0) "I understand (${timeout}s)" else "I understand"
AestheticDialog(
onDismissRequest = { if (timeout == 0) showGuides = false },
title = "Please note!",
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (timeout == 0) showGuides = false },
confirmEnabled = timeout == 0,
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Select the type of device you have: rooted or non-rooted. If you are unsure, choose Non-root and continue.",
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Non-rooted devices",
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = "Select Non-root and the app will handle everything. Tap Install Patched Snapchat when it appears. After it installs, do not open Snapchat yet. Continue the PurrfectSnap setup; once it finishes, you can open Snapchat and enjoy.",
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Rooted devices",
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start
)
Text(
text = "Make sure you have flashed LSPosed first. We recommend JingMatrix LSPosed or LSPosed Irena. After you select Root, the app will install the recommended Snapchat version. Do not open it yet; continue the PurrfectSnap setup. When setup finishes, enable PurrfectSnap in LSPosed and reboot your phone. Then start using Snapchat. We highly recommend detaching Snapchat from the Play Store with the Zygisk Detach module to prevent auto-updates.",
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "If you run into any installation issues, the solution will appear here. Please read it carefully.",
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.White)) {
append("Note: ")
}
append("New Accounts easily get locked! It is recommended to use an older account with PurrfectSnap.")
},
style = bodyStyle,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
SetupCard {
@@ -84,7 +213,10 @@ class InstallModeScreen(
)
),
selected = choice == InstallMode.ROOT,
onClick = { choice = InstallMode.ROOT }
onClick = {
choice = InstallMode.ROOT
skipSelected = false
}
)
ModeOption(
title = "Non-rooted device",
@@ -97,9 +229,54 @@ class InstallModeScreen(
)
),
selected = choice == InstallMode.NON_ROOT,
onClick = { choice = InstallMode.NON_ROOT }
onClick = {
choice = InstallMode.NON_ROOT
skipSelected = false
}
)
}
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(interactionSource)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = interactionSource,
indication = null
) {
skipSelected = true
choice = null
onSkipAutoSetup()
goNext()
},
color = Color.White.copy(alpha = 0.04f),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Skip Auto Setup",
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(16.dp)
)
}
}
}
}

View File

@@ -11,13 +11,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -26,11 +34,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -42,11 +54,21 @@ class MappingsScreen : SetupScreen() {
val coroutineScope = rememberCoroutineScope()
var infoText by remember { mutableStateOf(null as String?) }
var isGenerating by remember { mutableStateOf(false) }
var showCompletionNotice by remember { mutableStateOf(false) }
var completionCountdown by remember { mutableIntStateOf(10) }
fun finishMappings() {
if (isFirstRunFlow) {
showCompletionNotice = true
} else {
goNext()
}
}
if (infoText != null) {
fun dismiss() {
infoText = null
goNext()
finishMappings()
}
var visible by remember { mutableStateOf(false) }
@@ -64,6 +86,100 @@ class MappingsScreen : SetupScreen() {
}
}
LaunchedEffect(showCompletionNotice) {
if (showCompletionNotice) {
completionCountdown = 10
while (completionCountdown > 0) {
delay(1000)
completionCountdown--
}
}
}
if (showCompletionNotice) {
val confirmLabel = if (completionCountdown > 0) {
"I understand (${completionCountdown}s)"
} else {
"I understand"
}
AestheticDialog(
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
title = "Please note!",
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
confirmEnabled = completionCountdown == 0,
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "If you see the \"Account temporarily disabled\" error while logging in, do not worry. Follow these steps in order:",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "1. Reopen Snapchat and log in. This fixes it most of the time.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "2. If it still fails, tap the login button repeatedly. This usually covers the next chunk.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "3. If it still fails, clear Snapchat's data, disable any VPN, and log in again.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "For rooted users:",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold,
lineHeight = 18.sp
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Reopen Snapchat and log in. If it still fails, disable PurrfectSnap in LSPosed, log in, then re-enable PurrfectSnap.",
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isGenerating) return@launch
@@ -83,7 +199,7 @@ class MappingsScreen : SetupScreen() {
}
withContext(Dispatchers.Main) {
goNext()
finishMappings()
}
}.onFailure {
isGenerating = false

View File

@@ -12,6 +12,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,12 +20,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -52,8 +57,10 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
@@ -66,6 +73,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
@@ -86,6 +94,8 @@ class PatchSnapchatScreen : SetupScreen() {
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf("Auto Patcher is ready.") }
@Suppress("DEPRECATION")
val clipboard = LocalClipboardManager.current
var progress by remember { mutableFloatStateOf(-1f) }
var patchedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
@@ -98,6 +108,7 @@ class PatchSnapchatScreen : SetupScreen() {
var installRequested by rememberSaveable { mutableStateOf(false) }
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -112,7 +123,7 @@ class PatchSnapchatScreen : SetupScreen() {
LaunchedEffect(installVerified) { allowNext(installVerified) }
fun pushLog(message: String) {
if (logs.size > 16) logs.removeFirst()
if (logs.size > 120) logs.removeAt(0)
logs.add(message)
}
@@ -235,6 +246,10 @@ class PatchSnapchatScreen : SetupScreen() {
pushStatus("Patched build ready. Install to finish.")
}.onFailure {
error = it.message ?: it.toString()
it.stackTraceToString()
.lineSequence()
.filter { line -> line.isNotBlank() }
.forEach { line -> pushLog(line) }
pushStatus("Failed: ${it.message}")
}
isRunning = false
@@ -273,6 +288,66 @@ class PatchSnapchatScreen : SetupScreen() {
)
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = "Facing issues?",
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Got it",
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "How to fix installation errors",
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Issue: App cannot be installed because it conflicts with an existing package.",
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "Fix: Download Snapchat from the Play Store and uninstall it without keeping data. Run Auto Patcher again. If it still does not work, run:",
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "adb uninstall com.snapchat.android",
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = "Issue: App not installed because the package appears to be invalid.",
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = "Fix: Download and install JingMatrix LSPatch, then patch Snapchat 13.64.0.52 in Integrated mode. Select Embed Modules and embed the PurrfectSnap APK. Then choose Skip auto setup during PurrfectSnap setup to skip Auto Patcher.",
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
)
}
SetupCard {
StepTitle(
title = "Auto Patcher",
@@ -307,33 +382,48 @@ class PatchSnapchatScreen : SetupScreen() {
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val isDownloading = progress >= 0f
val isPatching = isRunning && downloadFinished && !isDownloading
Text(
text = if (progress >= 0f) {
"Downloading Snapchat ${(progress * 100).toInt()}%"
} else if (isRunning && downloadFinished) {
"Patching..."
} else {
"Initializing..."
text = when {
isDownloading -> "Downloading Snapchat ${(progress * 100).toInt()}%"
isPatching -> "Patching..."
else -> "Initializing..."
},
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.Medium
)
LinearProgressIndicator(
progress = if (progress >= 0f) progress.coerceIn(0f, 1f) else 0f,
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
if (isDownloading) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
} else {
LinearProgressIndicator(
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
}
}
}
LogsPanel(
logs = logs,
pulse = logPulse,
accent = accent
accent = accent,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog("Logs copied to clipboard.")
}
)
error?.let {
@@ -389,6 +479,36 @@ class PatchSnapchatScreen : SetupScreen() {
onClick = { installPatchedApk() },
enabled = true
)
val issuesInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(issuesInteraction)
.clickable(
interactionSource = issuesInteraction,
indication = null
) { showIssuesDialog = true }
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Facing issues?",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
val manualInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
@@ -520,7 +640,8 @@ private fun GradientActionButton(
private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush
accent: Brush,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val animatedBrush = Brush.linearGradient(
@@ -547,21 +668,54 @@ private fun LogsPanel(
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Logs",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
Row(
modifier = Modifier
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Logs",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
}
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { onCopy() }
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Text(
text = "Copy",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
}
}
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {

View File

@@ -0,0 +1,582 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import java.io.File
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import okhttp3.OkHttpClient
import okhttp3.Request
class RootInstallSnapchatScreen : SetupScreen() {
private val autoPatchServer = AutoPatchServer()
private val okHttpClient = OkHttpClient.Builder()
.callTimeout(1, TimeUnit.HOURS)
.connectTimeout(1, TimeUnit.HOURS)
.readTimeout(1, TimeUnit.HOURS)
.writeTimeout(1, TimeUnit.HOURS)
.build()
private val targetPackage = "com.snapchat.android"
@Composable
override fun Content() {
val coroutineScope = rememberCoroutineScope()
val logs = remember { mutableStateListOf("Snapchat installer is ready.") }
@Suppress("DEPRECATION")
val clipboard = LocalClipboardManager.current
var progress by remember { mutableFloatStateOf(-1f) }
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
val downloadedApk = remember(downloadedApkPath) { downloadedApkPath?.let(::File) }
var isRunning by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var downloadStartedAt by rememberSaveable { mutableStateOf(0L) }
var installVerified by rememberSaveable { mutableStateOf(false) }
var installRequested by rememberSaveable { mutableStateOf(false) }
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
val logPulse by rememberInfiniteTransition(label = "rootInstallLogPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
tween(durationMillis = 2200, easing = FastOutSlowInEasing),
RepeatMode.Reverse
),
label = "rootInstallLogPulseValue"
)
LaunchedEffect(Unit) { allowNext(false) }
LaunchedEffect(installVerified) { allowNext(installVerified) }
fun pushLog(message: String) {
if (logs.size > 120) logs.removeAt(0)
logs.add(message)
}
fun isSnapchatInstalledAfter(timestamp: Long): Boolean {
if (timestamp == 0L) return false
val info = runCatching {
context.androidContext.packageManager.getPackageInfo(targetPackage, 0)
}.getOrNull() ?: return false
return info.lastUpdateTime >= timestamp
}
fun isSnapchatInstalled(): Boolean {
return runCatching { context.androidContext.packageManager.getPackageInfo(targetPackage, 0) }.isSuccess
}
fun startInstallWatcher() {
if (downloadStartedAt == 0L) return
installWatcher?.cancel()
installWatcher = coroutineScope.launch {
repeat(80) {
if (isSnapchatInstalledAfter(downloadStartedAt)) {
installVerified = true
pushLog("Snapchat install confirmed. You're cleared to continue.")
return@launch
}
delay(1200)
}
}
}
LaunchedEffect(installRequested, downloadStartedAt) {
if (installRequested && downloadStartedAt > 0) {
startInstallWatcher()
}
}
LaunchedEffect(downloadStartedAt) {
if (downloadStartedAt > 0 && isSnapchatInstalledAfter(downloadStartedAt)) {
installVerified = true
}
}
LaunchedEffect(installVerified) {
if (installVerified) {
downloadedApk?.let { runCatching { it.delete() } }
downloadedApkPath = null
}
}
suspend fun pushStatus(message: String) = withContext(Dispatchers.Main) { pushLog(message) }
suspend fun setProgress(value: Float) = withContext(Dispatchers.Main) { progress = value }
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
?: context.androidContext.cacheDir
val outputFile = File(cacheDir, latestApk.apkName)
outputFile.outputStream().use { output ->
response.body?.byteStream()?.use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var read: Int
var totalRead = 0L
val totalSize = response.body?.contentLength() ?: -1L
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
totalRead += read
if (totalSize > 0) setProgress(totalRead.toFloat() / totalSize.toFloat())
}
} ?: return@withContext null
}
setProgress(-1f)
outputFile
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
installRequested = true
startInstallWatcher()
val uri = FileProvider.getUriForFile(
context.androidContext,
"${context.androidContext.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.androidContext.startActivity(intent)
}
fun markAlreadyInstalled() {
installRequested = false
installVerified = true
pushLog("Marked as installed manually. You're cleared to continue.")
}
fun startDownloadAndInstall() {
coroutineScope.launch(Dispatchers.Main) {
isRunning = true
allowNext(false)
error = null
progress = -1f
downloadedApkPath = null
installVerified = false
installRequested = false
downloadFinished = false
downloadStartedAt = System.currentTimeMillis()
logs.clear()
pushLog("Starting Snapchat download for rooted install.")
runCatching {
if (isSnapchatInstalled()) {
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then try again.")
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
}
pushStatus("Fetching recommended Snapchat APK...")
val downloaded = downloadSnapchatFromAutoPatchServer()
?: throw IllegalStateException("Download failed")
downloadedApkPath = downloaded.absolutePath
pushStatus("Download completed: ${downloaded.name}")
downloadFinished = true
pushStatus("Launching installer...")
installDownloadedApk()
}.onFailure {
error = it.message ?: it.toString()
it.stackTraceToString()
.lineSequence()
.filter { line -> line.isNotBlank() }
.forEach { line -> pushLog(line) }
pushStatus("Failed: ${it.message}")
}
isRunning = false
progress = -1f
}
}
val accent = remember {
Brush.linearGradient(
listOf(
PurrfectPalette.glowSecondary,
PurrfectPalette.glowPrimary
)
)
}
SetupCard {
StepTitle(
title = "Snapchat Installer",
subtitle = null,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.03f),
tonalElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
AnimatedVisibility(visible = isRunning || progress >= 0f) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val isDownloading = progress >= 0f
Text(
text = if (isDownloading) {
"Downloading Snapchat ${(progress * 100).toInt()}%"
} else {
"Preparing installer..."
},
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.Medium
)
if (isDownloading) {
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
} else {
LinearProgressIndicator(
color = PurrfectPalette.glowPrimary,
trackColor = Color.White.copy(alpha = 0.12f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(12.dp))
)
}
}
}
LogsPanel(
logs = logs,
pulse = logPulse,
accent = accent,
onCopy = {
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
pushLog("Logs copied to clipboard.")
}
)
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (installVerified) {
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
tint = Color.White
)
Text(
text = "Snapchat installed",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
if (downloadedApk == null) {
GradientActionButton(
label = "Download Snapchat",
icon = Icons.Filled.Download,
onClick = { startDownloadAndInstall() },
enabled = !isRunning
)
}
if (downloadedApk != null) {
GradientActionButton(
label = "Install Snapchat",
icon = Icons.Filled.Verified,
onClick = { installDownloadedApk() },
enabled = true
)
val manualInteraction = remember { MutableInteractionSource() }
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(manualInteraction)
.clickable(
interactionSource = manualInteraction,
indication = null
) { markAlreadyInstalled() }
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = Color.White.copy(alpha = 0.9f)
)
Text(
text = "Already Installed?",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun GradientActionButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
val interaction = remember { MutableInteractionSource() }
val gradient = Brush.horizontalGradient(listOf(PurrfectPalette.glowSecondary, PurrfectPalette.glowPrimary))
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Transparent)
.scaleOnPress(interaction)
.clickable(
enabled = enabled,
interactionSource = interaction,
indication = null,
onClick = onClick
),
tonalElevation = 0.dp,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Box(
modifier = Modifier
.background(if (enabled) gradient else Brush.horizontalGradient(listOf(Color.White.copy(alpha = 0.08f), Color.White.copy(alpha = 0.08f))))
.padding(vertical = 14.dp, horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
) {
Row(
modifier = Modifier.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White
)
Text(
text = label,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
}
}
}
}
@Composable
private fun LogsPanel(
logs: List<String>,
pulse: Float,
accent: Brush,
onCopy: () -> Unit
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val animatedBrush = Brush.linearGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.18f + 0.1f * pulse),
Color.Transparent,
PurrfectPalette.glowSecondary.copy(alpha = 0.12f + 0.1f * (1 - pulse))
),
start = Offset.Zero,
end = Offset(400f * (0.6f + pulse), 260f * (0.4f + (1 - pulse)))
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.03f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
modifier = Modifier
.background(animatedBrush)
.background(Color.Black.copy(alpha = 0.25f))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "Logs",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = Color.White
)
}
Surface(
shape = RoundedCornerShape(10.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { onCopy() }
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Text(
text = "Copy",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)
}
}
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
logs.forEach { line ->
Text(
text = "- $line",
color = PurrfectPalette.textPrimary,
fontSize = 13.sp,
lineHeight = 16.sp
)
}
}
}
}
}
}

View File

@@ -142,6 +142,28 @@ class SaveFolderScreen : SetupScreen() {
) {
Text(text = context.translation["setup.dialogs.select_save_folder_button"])
}
Spacer(modifier = Modifier.height(10.dp))
val defaultSrc = remember { MutableInteractionSource() }
OutlinedButton(
onClick = {
currentFolder = ""
context.config.root.downloader.saveFolder.set("")
context.sharedPreferences.edit().putBoolean("downloader_use_default_save_folder", true).apply()
context.config.writeConfig()
goNext()
},
interactionSource = defaultSrc,
modifier = Modifier
.fillMaxWidth()
.scaleOnPress(defaultSrc),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = Color.White
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
Text(text = "Use default location")
}
if (showNoPickerDialog) {
Dialog(onDismissRequest = { showNoPickerDialog = false }) {

View File

@@ -40,7 +40,10 @@ class ActivityLauncherHelper(
fun launch(intent: Intent, callback: ActivityLauncherCallback, onFailure: ((Throwable) -> Unit)?) {
if (this.callback != null) {
throw IllegalStateException("Already launching an activity")
val error = IllegalStateException("Already launching an activity")
AbstractLogger.directError("Ignored concurrent activity launch", error)
onFailure?.invoke(error)
return
}
this.callback = callback
@@ -56,7 +59,11 @@ class ActivityLauncherHelper(
fun requestPermission(permission: String, callback: ActivityLauncherCallback) {
if (this.callback != null) {
throw IllegalStateException("Already launching an activity")
AbstractLogger.directError(
"Ignored concurrent permission request",
IllegalStateException("Already launching an activity")
)
return
}
this.callback = callback
permissionResultLauncher.launch(permission)

View File

@@ -207,23 +207,30 @@ class AlertDialogs(
@Composable
@Suppress("UNCHECKED_CAST")
fun UniqueSelectionDialog(property: PropertyPair<*>) {
val disabledKey = property.key.params.disabledKey
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
add(0, "null")
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() ?: "null")
mutableStateOf(property.value.getNullable()?.toString() ?: (disabledKey ?: "null"))
}
DefaultDialogCard {
keys.forEachIndexed { index, item ->
fun select() {
selectedValue = item
property.value.setAny(if (index == 0) {
null
} else {
item
})
if (disabledKey != null && item == disabledKey) {
property.value.setAny(disabledKey)
return
}
property.value.setAny(if (disabledKey == null && index == 0) null else item)
}
Row(
@@ -279,6 +286,7 @@ class AlertDialogs(
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
@@ -362,6 +370,7 @@ class AlertDialogs(
fieldValue.value = it
},
singleLine = true,
shape = RoundedCornerShape(14.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="." />
<external-cache-path name="external_cache" path="." />
</paths>

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.0.0").get())
rootProject.ext.set("appVersionCode", 210)
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.1.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("1").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
rootProject.ext.set(
"buildHash",

View File

@@ -1,5 +1,38 @@
## v1.1.0
- test4
- Fix: message preview overlap in French.
- Fix: transparent gallery media send override dialog.
- Fix: message logger autopurge "Never" no longer resets to 3 days.
- Fix: crash during auto patching on certain older Android versions.
- Fix: audio mismatch.
- Fix: app lock crash.
- New: translations added.
- Fix: message list no longer autoscrolls.
- New: included an Easter egg.
- Fix: disable Bitmoji feature.
- UI: various UI elements fixed.
- UI: add friends dialog now includes an "Unselect all" button.
- UI: redesigned remaining UI elements.
- New: select folder screen has "Use default" option for cloned installs.
- Fix: export memories crash on certain devices.
- Fix: config state persistence on certain devices.
- Fix: notification crash on certain devices.
- Fix: friend suggestions (again).
- Change: removed pull down gesture to open menu; use Quick Add instead.
- New: custom color feature for export chat list.
- New: exported chat messages now include message logger.
- Fix: unsaveable snaps.
- UI: many UI elements made symmetrical.
- UI: redesigned file imports screen.
- Fix: config.json and update frequency dialogs now open on click.
- Fix: bypass camera roll limit.
- Fix: "no friends found" text now visible in bulk messaging action.
- Fix: search results list no longer autoscrolls while typing.
- New: in-app guides for rooted and non-rooted devices plus a troubleshooting guide.
- New: Snapchat auto downloader for rooted devices.
- Fix: quick tiles appearance.
- Fix: dialog position in Friend Tracker sort dialog.
- New: about section added.
- Improvement: optimizations throughout the app.
## v1.0.0
- test3
- Test

View File

@@ -1,5 +1,38 @@
## v1.1.0
- test2
- Fix: message preview overlap in French.
- Fix: transparent gallery media send override dialog.
- Fix: message logger autopurge "Never" no longer resets to 3 days.
- Fix: crash during auto patching on certain older Android versions.
- Fix: audio mismatch.
- Fix: app lock crash.
- New: translations added.
- Fix: message list no longer autoscrolls.
- New: included an Easter egg.
- Fix: disable Bitmoji feature.
- UI: various UI elements fixed.
- UI: add friends dialog now includes an "Unselect all" button.
- UI: redesigned remaining UI elements.
- New: select folder screen has "Use default" option for cloned installs.
- Fix: export memories crash on certain devices.
- Fix: config state persistence on certain devices.
- Fix: notification crash on certain devices.
- Fix: friend suggestions (again).
- Change: removed pull down gesture to open menu; use Quick Add instead.
- New: custom color feature for export chat list.
- New: exported chat messages now include message logger.
- Fix: unsaveable snaps.
- UI: many UI elements made symmetrical.
- UI: redesigned file imports screen.
- Fix: config.json and update frequency dialogs now open on click.
- Fix: bypass camera roll limit.
- Fix: "no friends found" text now visible in bulk messaging action.
- Fix: search results list no longer autoscrolls while typing.
- New: in-app guides for rooted and non-rooted devices plus a troubleshooting guide.
- New: Snapchat auto downloader for rooted devices.
- Fix: quick tiles appearance.
- Fix: dialog position in Friend Tracker sort dialog.
- New: about section added.
- Improvement: optimizations throughout the app.
## v1.0.0
- test 1
- Test.

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Vælg sprog",
@@ -42,7 +42,7 @@
"social": "Sociale",
"manage_scope": "Håndtér anvendelsesområde",
"messaging_preview": "Forhåndsvisning",
"scripts": "Scripts",
"scripts": "Skripter",
"manage_script_repos": "Håndtér script- referater",
"view_logger_history": "Loggerhistorik",
"better_location": "Bedre placering"
@@ -60,7 +60,7 @@
"version_title": "v{versionName}· af den evige",
"update_title": "PurrfectSnap opdatering",
"update_content": "Version{version}er tilgængelig!",
"update_button": "Download",
"update_button": "Hent",
"debug_build_summary_title": "Du kører en debug build af PurrfectSnap",
"debug_build_summary_content": "Version{versionName}({versionCode})",
"debug_build_summary_date": "Byggedato:{date}({days}dage siden)",
@@ -84,7 +84,7 @@
"clear_button": "Ryd",
"view_logger_history_button": "Vis loggerhistorik",
"ui_settings_title": "Brugerindstillinger",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptisk feedback",
"use_system_toasts_label": "Brug systemskåle",
"updates_title": "Opdateringer",
"auto_update_check": "Automatisk opdateringstjek",
@@ -107,7 +107,7 @@
"friend_notes_no_notes_to_backup": "Ingen noter at bakke op endnu",
"friend_notes_backup_success": "Ven noter bakkes op",
"friend_notes_restore_success": "Ven noter genoprettet",
"backup_button": "Backup",
"backup_button": "Sikkerhedskopi",
"restore_button": "Gendan",
"view_button": "Vis",
"customize_bottom_bar_title": "Tilpas underlinjen",
@@ -126,10 +126,10 @@
"merge_button": "Sammenflet",
"failed_to_open_file": "Kunne ikke åbne filen",
"merge_files_toast": "Sammenfletning{count}filer",
"remove_selected_tasks_title": "Er du sikker på du ønsker at fjerne valgte opgaver?",
"remove_all_tasks_title": "Er du sikker på, du ønsker at fjerne alle opgaver?",
"remove_selected_tasks_title": "Er du sikker p?, at du vil fjerne de valgte opgaver?",
"remove_all_tasks_title": "Er du sikker p?, at du vil fjerne alle opgaver?",
"delete_files_option": "Slet også filer",
"remove_selected_tasks_confirm": "Fjern{count}opgaver?",
"remove_selected_tasks_confirm": "Fjern {count} opgaver?",
"remove_all_tasks_confirm": "Fjern alle opgaver?"
},
"features": {
@@ -187,7 +187,7 @@
"streaks_expiration_text": "Udløber{eta}",
"streaks_expiration_text_expired": "Udløb",
"reminder_button": "Sæt påmindelse",
"delete_scope_confirm_dialog_title": "Er du sikker på, at du ønsker at slette en{scope}?",
"delete_scope_confirm_dialog_title": "Er du sikker p?, at du ?nsker at slette en {scope}?",
"notes_placeholder": "Klik for at tilføje en note"
},
"logged_stories": {
@@ -305,7 +305,7 @@
"clear_module_data_failed": "Kunne ikke rydde moduldata",
"delete_module_button": "Slet",
"delete_module_failed": "Kunne ikke slette modulet",
"documentation_button": "Docs",
"documentation_button": "Dokumentation",
"download_script_failed": "Kunne ikke downloade script",
"downloading_script": "Downloader script...",
"edit_module_button": "Redigér",
@@ -341,7 +341,7 @@
"no_scripts_folder_selected": "Vælg en scripts mappe først",
"no_scripts_available": "Ingen scripts tilgængelige",
"installed_button": "Installeret",
"download_button": "Download"
"download_button": "Hent"
},
"repos": {
"no_repos_added": "Ingen datalagre tilføjet",
@@ -407,7 +407,7 @@
"duplicate_rule_name_dialog_title": "Duplikeret regelnavn",
"duplicate_rule_name_dialog_text": "Der findes allerede en regel med dette navn. Vælg et nyt navn.",
"discard_changes_dialog_title": "Kassér ændringer?",
"discard_changes_dialog_text": "Du har ikke gemt ændringer. Kassere dem?",
"discard_changes_dialog_text": "Du har ikke gemt ?ndringer. Kass?r dem?",
"rule_subtitle": "Indstil udløsere og skoper til denne regel.",
"discard_button": "Kassér",
"enabled_label": "Aktiveret",
@@ -416,7 +416,7 @@
"delete_rule_dialog_text": "Er du sikker på, du vil slette denne regel?",
"no_repos_added": "Ingen datalagre tilføjet",
"import_dialog_title": "Importregler",
"bulk_import_button": "Bulk import",
"bulk_import_button": "Masseimport",
"individual_import_button": "Engangsimport",
"invalid_import_type_dialog_title": "Ugyldig import",
"invalid_import_type_dialog_text": "Den valgte filtype matcher ikke importtilstanden.",
@@ -546,7 +546,7 @@
"name": "Brug E2E kryptering"
},
"pin_conversation": {
"name": "Pin Conversation"
"name": "Fastg?r samtale"
},
"exclude_message_logger": {
"name": "Ekskl. fra brevlogger"
@@ -615,7 +615,7 @@
"description": "Udfører operationer såsom sletning af venner eller masse sletning af samtaler"
},
"regen_mappings": {
"name": "Regenerate Mappings",
"name": "Gendan mappinger",
"description": "Manuelt regenerere tilknytninger"
},
"change_language": {
@@ -690,7 +690,7 @@
},
"settings_menu": {
"default": "Standard",
"legacy": "Legacy"
"legacy": "Klassisk"
},
"path_format": {
"create_author_folder": "Opret mappe for hver forfatter",
@@ -708,7 +708,7 @@
"spotlight": "Spotlight"
},
"logging": {
"started": "Started",
"started": "Startet",
"success": "Succes",
"progress": "Fremskridt",
"failure": "Fejl"
@@ -716,7 +716,7 @@
"notifications": {
"chat_screenshot": "Skærmbillede",
"chat_screen_record": "Skærmoptegnelse",
"snap_replay": "Snap Replay",
"snap_replay": "Snap-genafspilning",
"camera_roll_save": "Gem kamerarulle",
"chat": "Chat",
"chat_reply": "Chat svar",
@@ -843,7 +843,7 @@
"ORIGINAL": "Originale medier",
"NOTE": "Lydnote",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"SAVEABLE_SNAP": "Snap der kan gemmes",
"null": "Snapchat- standard"
},
"strip_media_metadata": {
@@ -888,12 +888,12 @@
"null": "Automatisk"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatisk"
},
"snapchat_plus": {
"not_subscribed": "Ikke indskrevet",
"basic": "Grundlæggende",
"ad_free": "Ad Free",
"ad_free": "Reklamefri",
"null": "Standard"
},
"bypass_video_length_restriction": {
@@ -1016,19 +1016,19 @@
"friendly, casual, helpful, empathetic": "venlig, afslappet, hjælpsom, empatisk"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Uformel",
"formal": "Formel",
"friendly": "Venlig",
"humorous": "Humoristisk",
"empathetic": "Empatic",
"toxic": "Edgy",
"toxic": "Kantet",
"busy": "Travl"
},
"ai_temperature": {
"0.7": "Afbalanceret (0,7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatisk",
"en": "Engelsk",
"es": "Spansk",
"fr": "Fransk",
@@ -1112,7 +1112,7 @@
"translation_position": {
"above": "Ovenfor tekst",
"below": "Nedenfor tekst",
"inline": "Inline"
"inline": "Indlejret"
},
"source_language": {
"auto": "Detektér automatisk"
@@ -1139,7 +1139,7 @@
"description": "Indstil koordinaterne for den spoofed placering"
},
"walk_radius": {
"name": "Walk Radius",
"name": "Gangradius",
"description": "Tilfældigt gå rundt inden for denne radius (ft)"
},
"always_update_location": {
@@ -1191,7 +1191,7 @@
"description": "Bekræfter automatisk udvalgte handlinger"
},
"auto_updater": {
"name": "Auto Updater",
"name": "Auto-opdatering",
"description": "Kontrollerer automatisk for nye opdateringer"
},
"update_settings": {
@@ -1210,7 +1210,7 @@
"name": "Brugerindstillinger",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Haptisk feedback"
}
}
},
@@ -1282,7 +1282,7 @@
},
"downloader": {
"name": "Downloader",
"description": "Download Snapchat Media",
"description": "Hent Snapchat-medier",
"properties": {
"save_folder": {
"name": "Gem mappe",
@@ -1399,7 +1399,7 @@
"description": "Viser en lille forhåndsvisning ved siden af usete snaps i chat"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Bootstrap-override",
"description": "Overfører indstillinger for brugergrænseflade bootstrap",
"properties": {
"app_appearance": {
@@ -1445,7 +1445,7 @@
"description": "Vælg hvilke UI- komponenter der skal skjules"
},
"opera_media_quick_info": {
"name": "Opera Media Quick Info",
"name": "Opera Media hurtiginfo",
"description": "Viser nyttig information om medier såsom oprettelsesdato i opera viewers kontekstmenu"
},
"old_bitmoji_selfie": {
@@ -1503,7 +1503,7 @@
"description": "Ændre hvordan du interagerer med venner",
"properties": {
"bypass_screenshot_detection": {
"name": "Bypass Screenshot Detection",
"name": "Omg? sk?rmbilleddetektering",
"description": "Forhindrer Snapchat i at opdage når du tager et screenshot"
},
"anonymous_story_viewing": {
@@ -1593,7 +1593,7 @@
"description": "Gruppemeddelelser til en enkelt"
},
"chat_preview": {
"name": "Chat Preview",
"name": "Chat-forh?ndsvisning",
"description": "Viser en forhåndsvisning af modtagne meddelelser i anmeldelsen"
},
"media_preview": {
@@ -1765,7 +1765,7 @@
"description": "Vælg hvilken AI-tjeneste der skal bruges til at generere svar"
},
"ai_endpoint_url": {
"name": "AI Endpoint URL",
"name": "AI-endepunkt-URL",
"description": "API endpoint URL for AI-tjenesten (fx, OpenAI, lokal AI-server)"
},
"ai_model": {
@@ -1781,7 +1781,7 @@
"description": "System prompt, der definerer AI 's personlighed og adfærd"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "AI maks. tokens",
"description": "Maksimalt antal tokens (ord) AI kan bruge i svar"
},
"ai_temperature": {
@@ -1817,7 +1817,7 @@
"description": "Brug skabelonbreve hvis AI ikke genererer et svar"
},
"ai_request_timeout": {
"name": "AI Request Timeout",
"name": "AI-foresp?rgselstimeout",
"description": "Maksimal tid til at vente på AI respons (i sekunder)"
},
"ai_retry_attempts": {
@@ -1899,7 +1899,7 @@
"description": "Mindste forsinkelse i millisekunder før et snap åbnes"
},
"max_delay_ms": {
"name": "Max Delay (ms)",
"name": "Maks. forsinkelse (ms)",
"description": "Maksimal forsinkelse i millisekunder før et snap åbnes"
},
"queue_size": {
@@ -2090,7 +2090,7 @@
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"name": "Streaks-p?mindelse",
"description": "Periodisk fortæller dig om dine Streaks",
"properties": {
"interval": {
@@ -2124,11 +2124,11 @@
"description": "Viser det første oprettede brugernavn ved siden af det aktuelle brugernavn i profilsiden"
},
"bypass_camera_roll_limit": {
"name": "Bypass Camera Roll Limit",
"name": "Omg? kamerarullegr?nse",
"description": "Øger den maksimale mængde medier, du kan sende fra kameraet roll"
},
"custom_self_destruct_snap_delay": {
"name": "Custom Self Destruct Snap Delay",
"name": "Brugerdefineret selvdestruktionsforsinkelse for Snap",
"description": "Giver flere muligheder for selvdestruere timer, når du sender en Snap"
},
"composer_console": {
@@ -2156,7 +2156,7 @@
}
},
"spoof": {
"name": "Spoof",
"name": "Spoofing",
"description": "Spoof forskellige oplysninger om dig",
"properties": {
"play_store_installer_package_name": {
@@ -2208,7 +2208,7 @@
"description": "Giver dig mulighed for at vælge en video / lydfil fra galleriet"
},
"story_logger": {
"name": "Story Logger",
"name": "Story-logger",
"description": "Giver en historie om venner historier"
},
"call_recorder": {
@@ -2244,7 +2244,7 @@
}
},
"voice_note_auto_play": {
"name": "Voice Note Auto Play",
"name": "Automatisk afspilning af talenoter",
"description": "Afspiller automatisk den næste stemme note efter den nuværende er færdig"
},
"friend_notes": {
@@ -2260,7 +2260,7 @@
"description": "Forsøg at reparere Venne Feed Menuen som når enheden er offline kan den ikke vises korrekt"
},
"app_lock": {
"name": "App Lock",
"name": "Appl?s",
"description": "Forhindrer adgang til Snapchat uden adgangskode",
"properties": {
"lock_on_resume": {
@@ -2270,7 +2270,7 @@
}
},
"infinite_story_boost": {
"name": "Infinite Story Boost",
"name": "Uendelig Story-boost",
"description": "Bypass Story Boost Limit forsinkelse"
},
"meo_passcode_bypass": {
@@ -2399,7 +2399,7 @@
"LIVE_LOCATION_SHARE": "Live Placering Del",
"CREATIVE_TOOL_ITEM": "Kreativt værktøjs element",
"FAMILY_CENTER_INVITE": "Invitation til familiecenter",
"FAMILY_CENTER_ACCEPT": "Family Center Accept",
"FAMILY_CENTER_ACCEPT": "Family Center accept",
"FAMILY_CENTER_LEAVE": "Familiecenterorlov",
"STATUS_PLUS_GIFT": "Status plus gave",
"TINY_SNAP": "Lille snap",
@@ -2419,19 +2419,19 @@
"media_download_source": {
"none": "Ingen",
"pending": "Afventer",
"chat_media": "Chat Media",
"chat_media": "Chatmedier",
"story": "Historie",
"public_story": "Offentlig historie",
"spotlight": "Spotlight",
"profile_picture": "Profilbillede",
"story_logger": "Story Logger",
"story_logger": "Story-logger",
"message_logger": "BrevloggerComment",
"merged": "Sammenflettet",
"voice_call": "Stemmeopkald"
},
"chat_action_menu": {
"preview_button": "Forhåndsvisning",
"download_button": "Download",
"download_button": "Hent",
"delete_logged_message_button": "Slet indlæst brev",
"show_chat_edit_history": "Vis Chat Redigér historik",
"convert_message": "Konvertér brev"
@@ -2454,7 +2454,7 @@
"ORIGINAL": "Originale medier",
"NOTE": "Lydnote",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"SAVEABLE_SNAP": "Snap der kan gemmes",
"null": "Snapchat- standard",
"multiple_media_toast": "Du kan kun sende et medie ad gangen"
},
@@ -2597,7 +2597,7 @@
"cancel": "Annullér",
"save": "Gem",
"open": "Åbn",
"download": "Download",
"download": "Hent",
"send": "Send",
"restore_original": "Gendan original",
"convert_external_media": "Konverter eksterne medier"
@@ -2622,7 +2622,7 @@
"snap_replayed": "Snap genspillet",
"snap_replayed_twice": "Snap genspillet to gange",
"snap_screenshot": "Snap screenshot",
"snap_screen_record": "Snap Screen Record",
"snap_screen_record": "Snap-sk?rmoptagelse",
"i_can_see_you": "Jeg kan se dig"
},
"cleared_from_feed": "Renset fra foder",
@@ -2635,7 +2635,7 @@
"better_notifications": {
"button": {
"reply": "Svar",
"download": "Download",
"download": "Hent",
"mark_as_read": "Markér som læst"
}
},
@@ -2883,7 +2883,7 @@
"translation_position": {
"above": "Over",
"below": "Nedenfor",
"inline": "Inline"
"inline": "Indlejret"
},
"language_codes": {
"en": "Engelsk",
@@ -2984,7 +2984,7 @@
},
"delete_dialog": {
"title": "Slet log",
"message": "Er du sikker på, du vil slette alle logfiler? Denne handling kan ikke bringes til ophør.",
"message": "Er du sikker p?, du vil slette alle logfiler? Denne handling kan ikke fortrydes.",
"confirm": "Slet alle",
"cancel": "Annullér",
"progress": "Sletning{count}logfiler..."
@@ -3064,7 +3064,7 @@
}
},
"ui_settings_title": "Brugerindstillinger",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptisk feedback",
"updates_title": "Opdateringer",
"auto_update_check": "Automatisk opdateringstjek",
"update_check_frequency_daily": "Daglig",
@@ -3156,9 +3156,9 @@
"created_at": "Oprettet ved{date}",
"failed_to_open_file": "Kunne ikke åbne filen. Tjek logfiler for mere info",
"failed_to_get_file": "Kunne ikke få filen",
"download_button": "Download",
"download_button": "Hent",
"chat_attachment": "Bilag{index}",
"log_header_format": "{username}?{type}?{date}",
"log_header_format": "{username} ? {type} ? {date}",
"edited_at_text": "Edited to \"{message}\"på{date}",
"list_group_format": "Gruppe{name}",
"list_friend_format": "Ven{name}",
@@ -3182,7 +3182,7 @@
"failed_to_edit_message": "Kunne ikke redigere brev:{error}"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Uformel",
"formal": "Formel",
"friendly": "Venlig",
"humorous": "Humoristisk",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Sprache auswählen",
@@ -27,10 +27,10 @@
"tasks": "Aufgaben",
"features": "Eigenschaften",
"manage_rule_feature": "Regelmerkmal verwalten",
"home": "Home",
"home": "Startseite",
"home_about": "Über uns",
"home_settings": "Einstellungen",
"home_logs": "Logs",
"home_logs": "Protokolle",
"logger_history": "Geschichte von Logger",
"logged_stories": "Geloggte Geschichten",
"friend_tracker": "Fröhlicher Tracker",
@@ -58,7 +58,7 @@
"sections": {
"home": {
"version_title": "vgl{versionName}· von Eternal",
"update_title": "PurrfectSnap Update",
"update_title": "PurrfectSnap-Update",
"update_content": "Version{version}ist verfügbar!",
"update_button": "Downloads",
"debug_build_summary_title": "Sie führen einen Debug-Bau von PurrfectSnap",
@@ -107,8 +107,8 @@
"friend_notes_no_notes_to_backup": "Noch keine Notizen",
"friend_notes_backup_success": "Freundschaftsnoten gesichert",
"friend_notes_restore_success": "Freundschaftsnoten restauriert",
"backup_button": "Backup",
"restore_button": "Restore",
"backup_button": "Sicherung",
"restore_button": "Wiederherstellen",
"view_button": "Blick",
"customize_bottom_bar_title": "Anpassen Bottom Bar",
"customize_bottom_bar_subtitle": "Wählen Sie, welche Tabs auf Ihrem Heimbildschirm anzeigen",
@@ -116,7 +116,7 @@
"reset_button": "Zurück zur Übersicht",
"done_button": "Artikel 2",
"clear_friend_feed": "Klarer Friend Feed",
"test_mode_label": "Enable PurrAura",
"test_mode_label": "PurrAura aktivieren",
"disable_feature_loading_label": "Deaktivieren der Funktion",
"disable_auto_mapper_label": "Auto Mapper deaktivieren",
"disable_bypass_indicator_label": "Bypass Indikator deaktivieren"
@@ -126,10 +126,10 @@
"merge_button": "Verschmelzung",
"failed_to_open_file": "Datei nicht öffnen",
"merge_files_toast": "Zusammenführen{count}dateien",
"remove_selected_tasks_title": "Sind Sie sicher, dass Sie ausgewählte Aufgaben entfernen möchten?",
"remove_all_tasks_title": "Sind Sie sicher, dass Sie alle Aufgaben entfernen möchten?",
"remove_selected_tasks_title": "Sind Sie sicher, dass Sie die ausgew?hlten Aufgaben entfernen m?chten?",
"remove_all_tasks_title": "Sind Sie sicher, dass Sie alle Aufgaben entfernen m?chten?",
"delete_files_option": "Löschen von Dateien",
"remove_selected_tasks_confirm": "Entfernen{count}aufgaben?",
"remove_selected_tasks_confirm": "{count} Aufgaben entfernen?",
"remove_all_tasks_confirm": "Alle Aufgaben entfernen?"
},
"features": {
@@ -175,7 +175,7 @@
"e2ee_title": "End-to-End-Verschlüsselung",
"e2ee_subtitle": "Verwalten Sie Ihren gemeinsamen Schlüssel für diesen Freund.",
"export_base64_button": "Ausfuhrbasis64",
"import_base64_button": "Import Base64",
"import_base64_button": "Base64 importieren",
"invalid_key_size_32_bytes": "Ungültige Schlüsselgröße. Geben Sie einen 32-byte Schlüssel.",
"successfully_imported_key": "Schlüssel importiert erfolgreich.",
"failed_to_import_key": "Schlüssel nicht importiert:{message}",
@@ -187,7 +187,7 @@
"streaks_expiration_text": "Abzüge{eta}",
"streaks_expiration_text_expired": "Ausgenommen",
"reminder_button": "Zurück zur Übersicht",
"delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie eine löschen möchten{scope}?",
"delete_scope_confirm_dialog_title": "Sind Sie sicher, dass Sie {scope} l?schen m?chten?",
"notes_placeholder": "Klicken Sie auf eine Notiz hinzufügen"
},
"logged_stories": {
@@ -206,7 +206,7 @@
"save_selection_option": "Auswahl speichern",
"save_all_option": "Alle speichern",
"unsave_selection_option": "Auswahl nicht speichern",
"unsave_all_option": "Unsave All",
"unsave_all_option": "Alles nicht speichern",
"mark_selection_as_seen_option": "Mark ausgewählt Snap als gesehen",
"mark_all_as_seen_option": "Markieren Sie alle Snaps wie gesehen",
"delete_selection_option": "Auswahl löschen",
@@ -220,7 +220,7 @@
"list_friend_format": "Freund{name}",
"list_group_format": "Gruppe{name}",
"no_more_messages": "Keine Nachrichten mehr",
"reverse_order_checkbox": "Reverse Order",
"reverse_order_checkbox": "Reihenfolge umkehren",
"chat_attachment": "Anlage{index}",
"empty_message": "Freie Chatbotschaft",
"message_parse_failed": "Versäumte Nachricht",
@@ -245,14 +245,14 @@
"choose_location_button": "Wählen Sie einen Standort",
"manual_coordinates_hint": "Feinabstimmung der Koordinaten manuell.",
"saved_coordinates_subtitle": "Verwalten Sie Ihre gespeicherten Spoof-Standorte",
"teleport_to_friend_button": "Teleport to Friend",
"teleport_to_friend_button": "Zu Freund teleportieren",
"spoof_location_toggle": "Standort des Spoof",
"suspend_location_updates": "Standort-Updates verhängen",
"saved_coordinates_title": "Gespeicherte Koordinaten",
"no_saved_coordinates_hint": "Keine gespeicherten Koordinaten",
"delete_dialog_title": "Gespeicherte Koordinaten löschen",
"delete_dialog_message": "Sind Sie sicher, dass Sie diese gespeicherte Koordinaten löschen möchten?",
"teleport_to_friend_title": "Teleport to Friend",
"delete_dialog_message": "Sind Sie sicher, dass Sie diese gespeicherte Koordinate l?schen m?chten?",
"teleport_to_friend_title": "Zu Freund teleportieren",
"search_bar": "Suche",
"no_friends_map": "Keine Freunde auf der Karte",
"no_friends_found": "Keine Freunde gefunden"
@@ -277,7 +277,7 @@
},
"reset_config": {
"title": "Zurück zur Übersicht",
"content": "Sind Sie sicher, dass Sie die config zurücksetzen wollen?",
"content": "Sind Sie sicher, dass Sie die Konfiguration zur?cksetzen m?chten?",
"success_toast": "Zurücksetzen erfolgreich"
},
"quick_actions_dialog": {
@@ -285,8 +285,8 @@
"subtitle": "Zugriff auf Ihre Lieblingswerkzeuge schneller"
},
"export_config": {
"title": "Export Sensitive Daten?",
"content": "Möchten Sie die Config mit sensiblen Daten exportieren? (Sowie Ortskoordinaten, etc.)"
"title": "Sensible Daten exportieren?",
"content": "M?chten Sie die Konfiguration mit sensiblen Daten exportieren? (z. B. Standortkoordinaten usw.)"
},
"messaging_action": {
"title": "Wählen Sie Inhaltstypen zu verarbeiten",
@@ -347,9 +347,9 @@
"no_repos_added": "Keine Repositorys hinzugefügt",
"add_repo_button": "Repository hinzufügen",
"add_repo_dialog_title": "Repository hinzufügen",
"repo_url_label": "Repository URL",
"repo_url_label": "Repository-URL",
"add_button": "Hinzufügen",
"invalid_repo_title": "Invalid Repository",
"invalid_repo_title": "Ung?ltiges Repository",
"invalid_repo_error": "Dieses Repository fehlt den benötigten Daten.",
"repo_added_toast": "Repository hinzugefügt",
"add_repo_failed_toast": "Fehler beim Hinzufügen von Repository:{message}",
@@ -360,7 +360,7 @@
},
"friend_tracker": {
"rules_tab": "Regeln",
"logs_tab": "Logs",
"logs_tab": "Protokolle",
"catalog_button": "Katalog",
"add_rule_button": "Artikel",
"import_button": "Einfuhr",
@@ -377,7 +377,7 @@
"no_logs_found": "Keine Protokolle gefunden",
"no_rules_found": "Keine Regeln gefunden",
"export_logs_dialog_title": "Exportprotokolle",
"export_logs_dialog_confirm_text": "Logs mit aktuellen Filtern exportieren?",
"export_logs_dialog_confirm_text": "Protokolle mit aktuellen Filtern exportieren?",
"export_as_button": "Ausfuhr{type}",
"new_rule_title": "Neue Regel",
"edit_rule_title": "Artikel",
@@ -406,10 +406,10 @@
"cannot_save_rule_dialog_text": "Füllen Sie die fehlenden Felder aus, um diese Regel zu speichern.",
"duplicate_rule_name_dialog_title": "Duplikate Regelname",
"duplicate_rule_name_dialog_text": "Eine Regel mit diesem Namen existiert bereits. Such dir einen neuen Namen.",
"discard_changes_dialog_title": "Änderungen deaktivieren?",
"discard_changes_dialog_text": "Sie haben keine Änderungen. Sie ablenken?",
"discard_changes_dialog_title": "?nderungen verwerfen?",
"discard_changes_dialog_text": "Sie haben ungespeicherte ?nderungen. Verwerfen?",
"rule_subtitle": "Konfigurieren Sie Trigger und Scopes für diese Regel.",
"discard_button": "Discard",
"discard_button": "Verwerfen",
"enabled_label": "Ermöglicht",
"disabled_label": "Behinderte",
"delete_rule_dialog_title": "Regel",
@@ -423,13 +423,13 @@
"export_dialog_title": "Ausfuhrregeln",
"bulk_export_button": "Ausfuhr",
"individual_export_button": "Einzelausfuhr",
"reverse_order_checkbox": "Reverse Order",
"reverse_order_checkbox": "Reihenfolge umkehren",
"delete_logs_dialog_title": "Logs löschen",
"delete_logs_dialog_confirm_text": "Löschen Sie alle Protokolle, die den aktuellen Filtern entsprechen?",
"select_friends_groups_button": "Freunde / Gruppen auswählen"
},
"friend_tracker_export": {
"title": "Export Friend Tracker",
"title": "Freund-Tracker exportieren",
"save_button": "Speichern",
"back_button_description": "Zurück",
"expand_button_description": "Kategorie erweitern oder zusammenbrechen",
@@ -453,9 +453,9 @@
"no_repos_added": "Keine Repositorys hinzugefügt",
"add_repo_button": "Repository hinzufügen",
"add_repo_dialog_title": "Repository hinzufügen",
"repo_url_label": "Repository URL",
"repo_url_label": "Repository-URL",
"add_button": "Hinzufügen",
"invalid_repo_title": "Invalid Repository",
"invalid_repo_title": "Ung?ltiges Repository",
"invalid_repo_error": "Dieses Repository fehlt den benötigten Daten.",
"repo_added_toast": "Repository hinzugefügt",
"add_repo_failed_toast": "Fehler beim Hinzufügen von Repository:{message}",
@@ -532,11 +532,11 @@
}
},
"auto_open_snaps": {
"name": "Auto Open Snaps",
"name": "Snaps automatisch ?ffnen",
"description": "Öffnet automatisch Snaps beim Empfang",
"options": {
"blacklist": "Ausschließen von Auto Open Snaps",
"whitelist": "Auto Open Snaps"
"whitelist": "Snaps automatisch ?ffnen"
}
},
"hide_friend_feed": {
@@ -546,7 +546,7 @@
"name": "E2E Verschlüsselung verwenden"
},
"pin_conversation": {
"name": "Pin Conversation"
"name": "Unterhaltung anheften"
},
"exclude_message_logger": {
"name": "Ausschließen von Message Logger"
@@ -595,7 +595,7 @@
},
"actions": {
"clean_snapchat_cache": {
"name": "Clean Snapchat Cache",
"name": "Snapchat-Cache leeren",
"description": "Reinigt den Snapchat Cache"
},
"manage_friend_list": {
@@ -607,15 +607,15 @@
"description": "Exportiert Konversationsnachrichten in eine JSON/HTML/TXT-Datei"
},
"export_memories": {
"name": "Export Memories",
"name": "Memories exportieren",
"description": "Exportiert Speicher in eine ZIP-Datei"
},
"bulk_messaging_action": {
"name": "Bulk Messaging Action",
"name": "Massen-Nachrichtenaktion",
"description": "Führt Operationen wie das Löschen von Freunden oder das Löschen von Gesprächen durch"
},
"regen_mappings": {
"name": "Regenerate Mappings",
"name": "Zuordnungen neu erstellen",
"description": "Manuelle Mappings regenerieren"
},
"change_language": {
@@ -668,7 +668,7 @@
"auto_delete_sent_messages": "🗑️ Auto Löschen Sent-Nachrichten",
"mark_snaps_as_seen": "👀 Mark Snaps als gesehen",
"mark_stories_as_seen_locally": "👀 Mark Stories als lokal gesehen",
"conversation_info": "👤 Conversation Info",
"conversation_info": "?? Gespr?chsinfo",
"e2e_encryption": "🔒 E2E Verschlüsselung verwenden",
"message_logger": "📝 Nachrichtenlogger",
"auto_read": "✅ Auto Lesen",
@@ -726,9 +726,9 @@
"speaking": "Apropos",
"chat_reaction": "DM-Reaktion",
"group_chat_reaction": "Gruppenreaktion",
"initiate_audio": "Incoming Audio Call",
"initiate_audio": "Eingehender Audioanruf",
"abandon_audio": "Versäumter Audio-Anruf",
"initiate_video": "Incoming Video Call",
"initiate_video": "Eingehender Videoanruf",
"abandon_video": "Vermisste Videoanruf",
"map_live_location": "Karte Live Standort"
},
@@ -786,13 +786,13 @@
"null": "Spuck nicht Quelle"
},
"custom_streaks_expiration_format": {
"null": "System Default"
"null": "Systemstandard"
},
"preferred_transcription_lang": {
"null": "Snapchat Default verwenden"
},
"custom_emoji_font": {
"null": "Default Emoji Font"
"null": "Standard-Emoji-Schriftart"
},
"custom_shared_library": {
"null": "Standardbibliothek verwenden"
@@ -833,18 +833,18 @@
"null": "Standard-Code"
},
"preset": {
"null": "Default Preset"
"null": "Standard-Preset"
},
"app_appearance_override": {
"title": "Erscheinung"
},
"gallery_media_send_override": {
"always_ask": "Immer fragen",
"ORIGINAL": "Original Media",
"ORIGINAL": "Originalmedien",
"NOTE": "Audionote",
"SNAP": "Schnapper",
"SAVEABLE_SNAP": "Speichern von Snap",
"null": "Snapchat Default"
"null": "Snapchat-Standard"
},
"strip_media_metadata": {
"hide_caption_text": "Verstecken Caption Text",
@@ -875,7 +875,7 @@
"camera": "Kamera",
"discover": "Entdecken",
"spotlight": "Scheinwerfer",
"null": "Snapchat Default"
"null": "Snapchat-Standard"
},
"spotlight_comments_username_icon": {
"user": "Benutzername Icon",
@@ -888,7 +888,7 @@
"null": "Automatische Übersetzung"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatisch"
},
"snapchat_plus": {
"not_subscribed": "Nicht angemeldet",
@@ -902,9 +902,9 @@
"null": "Fehler"
},
"old_bitmoji_selfie": {
"2d": "2D Bitmoji",
"3d": "3D Bitmoji",
"null": "Default Bitmoji"
"2d": "2D-Bitmoji",
"3d": "3D-Bitmoji",
"null": "Standard-Bitmoji"
},
"disable_confirmation_dialogs": {
"erase_message": "Nachricht löschen",
@@ -1017,18 +1017,18 @@
},
"ai_response_style": {
"casual": "Lässig",
"formal": "Formal",
"formal": "Formell",
"friendly": "Freundlichkeit",
"humorous": "Humorvoll",
"empathetic": "Empfängnis",
"toxic": "Edgy",
"toxic": "Provokant",
"busy": "Beschäftigt"
},
"ai_temperature": {
"0.7": "Bilanziert (0.7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatisch",
"en": "Englisch",
"es": "Spanisch",
"fr": "Französisch",
@@ -1090,7 +1090,7 @@
"external_media_messages": "Externe Medien",
"voice_note_messages": "Sprachhinweise",
"sticker_messages": "Aufkleber",
"tiny_snap_messages": "Tiny Snaps",
"tiny_snap_messages": "Mini-Snaps",
"map_reaction_messages": "Kartenreaktionen",
"half_swipes": "Halbschweine"
},
@@ -1151,7 +1151,7 @@
"description": "Verhindert, dass Ihr Standort aktualisiert wird"
},
"spoof_battery_level": {
"name": "Spoof Battery Level",
"name": "Akkustand vort?uschen",
"description": "Läust den Batteriepegel Ihres Geräts auf der Karte\nWert muss zwischen 0 und 100 liegen"
},
"spoof_headphones": {
@@ -1255,7 +1255,7 @@
"description": "Stellt die Standardgeschwindigkeit für die Wiedergabe von Videos fest\nWert muss zwischen 0,1 und 4,0 liegen"
},
"video_playback_rate_slider": {
"name": "Video Playback Rate Slider",
"name": "Regler f?r Video-Wiedergabegeschwindigkeit",
"description": "Fügt einen Slider im Kontextmenü der Oper hinzu, um die Videowiedergaberate zu ändern\nHinweis: Änderungen gelten nur für nachfolgende Videos"
},
"disable_google_play_dialogs": {
@@ -1263,7 +1263,7 @@
"description": "Verhindern Sie Google Play Services-Verfügbarkeitsdialoge von angezeigt werden"
},
"default_volume_controls": {
"name": "Default Volume Controls",
"name": "Standard-Lautst?rkeregelung",
"description": "Erzwingt Snapchat, Systemvolumensteuerungen zu verwenden"
},
"disable_telecom_framework": {
@@ -1305,7 +1305,7 @@
"description": "Ermöglicht die gleichen Medien mehrfach heruntergeladen werden"
},
"merge_overlays": {
"name": "Merge Overlays",
"name": "Overlays zusammenf?hren",
"description": "Kombiniert den Text und die Medien eines Snap in eine einzelne Datei"
},
"force_image_format": {
@@ -1349,11 +1349,11 @@
"description": "Setzen Sie den konstanten Ratefaktor für den Video-Encoder\nVon 0 bis 51 für libx264"
},
"video_bitrate": {
"name": "Video Bitrate",
"name": "Video-Bitrate",
"description": "Setzen Sie die Videobitrate (kbps)"
},
"audio_bitrate": {
"name": "Audio Bitrate",
"name": "Audio-Bitrate",
"description": "Einstellen der Audiobitrate (kbps)"
},
"custom_video_codec": {
@@ -1399,7 +1399,7 @@
"description": "Zeigt eine kleine Vorschau neben ungesehenen Snaps im Chat an"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Bootstrap-Override",
"description": "Überträgt Benutzeroberfläche Bootstrap Einstellungen",
"properties": {
"app_appearance": {
@@ -1407,13 +1407,13 @@
"description": "Setzt eine anhaltende Erscheinung"
},
"home_tab": {
"name": "Home Tab",
"name": "Startseite-Tab",
"description": "Übertreibt die Startkarte beim Öffnen von Snapchat"
}
}
},
"map_friend_nametags": {
"name": "Enhanced Friend Map Nametags",
"name": "Erweiterte Freundkarten-Namensschilder",
"description": "Verbessert die Namensschilder von Freunden auf der Snapmap"
},
"prevent_message_list_auto_scroll": {
@@ -1445,7 +1445,7 @@
"description": "Wählen Sie aus, welche UI-Komponenten zu verstecken sind"
},
"opera_media_quick_info": {
"name": "Opera Media Quick Info",
"name": "Opera Media Schnellinfo",
"description": "Zeigt nützliche Informationen von Medien wie Erstellungsdatum im Kontextmenü Opera Viewer"
},
"old_bitmoji_selfie": {
@@ -1465,7 +1465,7 @@
"description": "Schließen Sie das Friend Feed Menü automatisch nach dem Drücken einer Einstelltaste"
},
"vertical_story_viewer": {
"name": "Vertical Story Viewer",
"name": "Vertikaler Story-Viewer",
"description": "Ermöglicht den vertikalen Story Viewer für alle Geschichten"
},
"enable_friend_feed_menu_bar": {
@@ -1515,7 +1515,7 @@
"description": "Verhindert, dass jemand weiß, dass Sie ihre Geschichte neu beobachtet haben"
},
"hide_peek_a_peek": {
"name": "Hide Peek-a-Peek",
"name": "Peek-a-Peek ausblenden",
"description": "Verhindert die Benachrichtigung von gesendet werden, wenn Sie halb schwipe in einen Chat"
},
"hide_bitmoji_presence": {
@@ -1539,11 +1539,11 @@
"description": "Fügt eine Schaltfläche hinzu, um einen Snap zu markieren, wie beim Betrachten gesehen.\nDies funktioniert auch, wenn Stealth-Modus aktiviert ist"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"name": "Beim Als-gesehen-Markieren ?berspringen",
"description": "Überspringen Sie automatisch zum nächsten Snap, wenn Sie einen Snap wie gesehen markieren.\nVerwendung in Kombination mit Mark Snap als Seen Button"
},
"loop_media_playback": {
"name": "Loop Media Playback",
"name": "Medienwiedergabe in Schleife",
"description": "Loops Medienwiedergabe beim Anzeigen von Snaps / Stories"
},
"disable_replay_in_ff": {
@@ -1707,7 +1707,7 @@
"description": "Wählen Sie, wie Galeriemedien gesendet werden"
},
"include_camera_snaps": {
"name": "Include Camera Snaps",
"name": "Kamera-Snaps einbeziehen",
"description": "Zeigen Sie auch den Override Dialog für Kamera Snaps"
}
}
@@ -1729,7 +1729,7 @@
"description": "Erlauben Sie, Gruppeninformationen nach dem Kick zu sehen"
},
"double_tap_chat_action": {
"name": "Double Tap Chat Action",
"name": "Doppeltippen-Chat-Aktion",
"description": "Führen Sie eine benutzerdefinierte Aktion, wenn Sie eine Nachricht im Chat doppelt tippen"
},
"double_tap_chat_action_custom_emoji": {
@@ -1761,7 +1761,7 @@
"description": "Verwenden Sie KI, um intelligente Auto-Replies anstelle von Template-Nachrichten zu generieren"
},
"ai_provider": {
"name": "AI Provider",
"name": "KI-Anbieter",
"description": "Wählen Sie aus, welchen KI-Dienst zur Generierung von Antworten verwendet wird"
},
"ai_endpoint_url": {
@@ -1777,11 +1777,11 @@
"description": "API-Schlüssel zur Authentifizierung mit dem AI-Service"
},
"ai_system_prompt": {
"name": "AI System Prompt",
"name": "KI-System-Prompt",
"description": "Systemaufforderung, die die Persönlichkeit und das Verhalten der KI definiert"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "KI-Max.-Tokens",
"description": "Maximale Anzahl von Token (Worte) kann die KI in Antworten verwenden"
},
"ai_temperature": {
@@ -1817,11 +1817,11 @@
"description": "Verwenden Sie Template-Nachrichten, wenn KI eine Antwort nicht generiert"
},
"ai_request_timeout": {
"name": "AI Request Timeout",
"name": "KI-Anfrage-Timeout",
"description": "Maximale Zeit, um auf KI-Antwort zu warten (in Sekunden)"
},
"ai_retry_attempts": {
"name": "AI Retry Attempts",
"name": "KI-Wiederholungsversuche",
"description": "Anzahl der Zeiten, um KI-Anforderungen wiederherzustellen, wenn sie scheitern"
}
}
@@ -1895,11 +1895,11 @@
"description": "Ermöglicht Auto Open Snaps im Hintergrund laufen. Anmerkung: Dies wird Ihren Akku deutlich entleeren"
},
"min_delay": {
"name": "Min Delay (ms)",
"name": "Min. Verz?gerung (ms)",
"description": "Mindestverzögerung in Millisekunden vor dem Öffnen eines Schnapps"
},
"max_delay_ms": {
"name": "Max Delay (ms)",
"name": "Max. Verz?gerung (ms)",
"description": "Maximale Verzögerung in Millisekunden vor dem Öffnen eines Schnapps"
},
"queue_size": {
@@ -1907,11 +1907,11 @@
"description": "Maximale Anzahl der Snaps in Warteschlange zu halten"
},
"retry_attempts": {
"name": "Retry Attempts",
"name": "Wiederholungsversuche",
"description": "Anzahl der Zeiten, um einen Snap zu öffnen, wenn es versagt"
},
"retry_delay": {
"name": "Retry Delay (ms)",
"name": "Wiederholungsverz?gerung (ms)",
"description": "Verzögerung in Millisekunden zwischen Retry-Ansätzen"
}
}
@@ -2068,7 +2068,7 @@
"description": "Setzt eine benutzerdefinierte Kameraauflösung, Breite x Höhe (z.B. 1920x1080).\nDie benutzerdefinierte Auflösung muss von Ihrem Gerät unterstützt werden"
},
"front_custom_frame_rate": {
"name": "Front Custom Frame Rate",
"name": "Benutzerdefinierte Front-Bildrate",
"description": "Übertreibt die vordere Kamera-Rahmenrate"
},
"back_custom_frame_rate": {
@@ -2076,7 +2076,7 @@
"description": "Übertreibt die Rückfahrkamera-Rahmenrate"
},
"force_camera_source_encoding": {
"name": "Force Camera Source Encoding",
"name": "Kameraquellenkodierung erzwingen",
"description": "Erzwingt die Kameraquellencodierung"
},
"startup_default_camera": {
@@ -2090,7 +2090,7 @@
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"name": "Streaks-Erinnerung",
"description": "Benachrichtigt Sie regelmäßig über Ihre Streaks",
"properties": {
"interval": {
@@ -2132,11 +2132,11 @@
"description": "Gibt mehr Optionen für den selbstzerstörenden Timer beim Senden eines Snap"
},
"composer_console": {
"name": "Composer Console",
"name": "Composer-Konsole",
"description": "Ermöglicht die Ausführung von JavaScript-Code in Composer (nur arm64)"
},
"composer_logs": {
"name": "Composer Logs",
"name": "Composer-Logs",
"description": "Redirects Konsolenprotokolle von Composer zu PurrfectSnap"
}
}
@@ -2176,11 +2176,11 @@
"description": "Kraftverkehr zum Bericht WLAN statt mobiler Daten"
},
"spoof_device_id": {
"name": "Spoof Device ID",
"name": "Ger?te-ID spoofen",
"description": "Überschreiben Sie die Android-ID an Snapchat gesendet",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Android-ID spoofen",
"description": "Überschreiben Sie die Android-ID an Snapchat mit einem benutzerdefinierten Wert"
},
"custom_android_id": {
@@ -2204,7 +2204,7 @@
"description": "Konvertiert Snaps, um externe Medien lokal zu chatten. Dies erscheint im Kontextmenü des Chat-Downloads"
},
"media_file_picker": {
"name": "Media File Picker",
"name": "Medien-Dateiauswahl",
"description": "Erlaubt Ihnen, jede Video/Audio-Datei aus der Galerie auszuwählen"
},
"story_logger": {
@@ -2244,7 +2244,7 @@
}
},
"voice_note_auto_play": {
"name": "Voice Note Auto Play",
"name": "Sprachnotizen automatisch abspielen",
"description": "Spielt automatisch die nächste Sprachnote, nachdem die aktuelle beendet ist"
},
"friend_notes": {
@@ -2260,7 +2260,7 @@
"description": "Versuchen Sie, das Friend Feed Menü zu reparieren, als wenn das Gerät offline ist, kann es nicht korrekt angezeigt werden"
},
"app_lock": {
"name": "App Lock",
"name": "App-Sperre",
"description": "Verhindert Zugriff auf Snapchat ohne Passcode",
"properties": {
"lock_on_resume": {
@@ -2270,7 +2270,7 @@
}
},
"infinite_story_boost": {
"name": "Infinite Story Boost",
"name": "Unendlicher Story-Boost",
"description": "Bypass the Story Boost Limit Verzögerung"
},
"meo_passcode_bypass": {
@@ -2278,7 +2278,7 @@
"description": "Bypass the My Eyes Nur Passcode\nDies funktioniert nur, wenn der Passcode korrekt eingegeben wurde, bevor"
},
"no_friend_score_delay": {
"name": "No Friend Score Delay",
"name": "Keine Freundes-Score-Verz?gerung",
"description": "Entfernt die Verzögerung beim Betrachten eines Friends Score"
},
"best_friend_pinning": {
@@ -2301,10 +2301,10 @@
},
"add_friend_source_spoof": {
"name": "Friend Source Spoof",
"description": "Spoofs the source of a Friend Request"
"description": "Spooft die Quelle einer Freundschaftsanfrage"
},
"hidden_snapchat_plus_features": {
"name": "Hidden Snapchat Plus Features",
"name": "Versteckte Snapchat-Plus-Funktionen",
"description": "Ermöglicht unveröffentlichte / Beta Snapchat Plus Funktionen\nVielleicht nicht auf älteren Snapchat-Versionen arbeiten"
},
"custom_streaks_expiration_format": {
@@ -2451,11 +2451,11 @@
},
"gallery_media_send_override": {
"always_ask": "Immer fragen",
"ORIGINAL": "Original Media",
"ORIGINAL": "Originalmedien",
"NOTE": "Audionote",
"SNAP": "Schnapper",
"SAVEABLE_SNAP": "Speichern von Snap",
"null": "Snapchat Default",
"null": "Snapchat-Standard",
"multiple_media_toast": "Sie können nur eine Medien zu einer Zeit senden"
},
"mark_as_seen": {
@@ -2500,7 +2500,7 @@
"following": "Folgen",
"suggested": "Vorschlag",
"incoming": "Einkommen",
"incoming_follower": "Incoming Follower"
"incoming_follower": "Eingehender Follower"
},
"bulk_messaging_action": {
"actions.title": "Maßnahmen",
@@ -2508,14 +2508,14 @@
"progress_status": "Verarbeitung{index}von{total}",
"selection_dialog_continue_button": "Fortsetzung",
"confirmation_dialog": {
"title": "Bist du sicher?",
"title": "Sind Sie sicher?",
"message": "Dies wird alle ausgewählten Auswirkungen haben. Diese Aktion kann nicht rückgängig gemacht werden."
},
"actions": {
"remove_friends": "Freunde entfernen",
"clear_conversations": "Klare Konversationen",
"clear_friend_feed": "Klarer Freund Feed ({count})",
"unfollow": "Unfollow",
"unfollow": "Nicht mehr folgen",
"remove": "Entfernen"
},
"leave_groups": "Weg{count}gruppen",
@@ -2599,12 +2599,12 @@
"open": "Öffnen",
"download": "Downloads",
"send": "Bitte",
"restore_original": "Restore Original",
"restore_original": "Original wiederherstellen",
"convert_external_media": "Externe Medien umrechnen"
},
"tracker_events": {
"conversation_enter": "Konversation eingeben",
"conversation_exit": "Conversation Exit",
"conversation_exit": "Unterhaltung verlassen",
"started_typing": "Gestartet Typing",
"stopped_typing": "Gestoppte Typisierung",
"started_speaking": "Gestartet Apropos",
@@ -2619,10 +2619,10 @@
"message_reaction_add": "Nachrichtenreaktion Hinzufügen",
"message_reaction_remove": "Nachricht Reaction Entfernen",
"snap_opened": "Snap geöffnet",
"snap_replayed": "Snap Replayed",
"snap_replayed_twice": "Snap Replayed Twice",
"snap_screenshot": "Snap Screenshot",
"snap_screen_record": "Snap Screen Record",
"snap_replayed": "Snap erneut abgespielt",
"snap_replayed_twice": "Snap zweimal erneut abgespielt",
"snap_screenshot": "Snap-Screenshot",
"snap_screen_record": "Snap-Bildschirmaufnahme",
"i_can_see_you": "Ich kann dich sehen"
},
"cleared_from_feed": "Vom Futter befreit",
@@ -2646,8 +2646,8 @@
"background_option": "Hintergrund"
},
"call_start_confirmation": {
"dialog_title": "Start Call",
"dialog_message": "Willst du sicher einen Anruf starten?"
"dialog_title": "Anruf starten",
"dialog_message": "Sind Sie sicher, dass Sie einen Anruf starten m?chten?"
},
"half_swipe_notifier": {
"notification_channel_name": "Halbschwein",
@@ -2661,7 +2661,7 @@
"gif": "GIF",
"external_media": "Externe Medien",
"note": "Anmerkung",
"original_story": "Original Story"
"original_story": "Original-Story"
},
"select_attachments_title": "Anhänge auswählen",
"download_started_toast": "Download gestartet",
@@ -2701,8 +2701,8 @@
},
"confirmation_dialogs": {
"title": "End-to-End-Verschlüsselung",
"confirmation_1": "WARNING: Dies wird Ihren vorhandenen Schlüssel überschreiben. Sie werden den Zugriff auf alle verschlüsselten Nachrichten von diesem Freund verlieren. Bist du sicher, dass du weitermachen willst?",
"confirmation_2": "Bist du sicher, dass du weitermachen willst? Das ist deine letzte Chance."
"confirmation_1": "WARNUNG: Dadurch wird Ihr vorhandener Schl?ssel ?berschrieben. Sie verlieren den Zugriff auf alle verschl?sselten Nachrichten von diesem Freund. Sind Sie sicher, dass Sie fortfahren m?chten?",
"confirmation_2": "Sind Sie wirklich sicher, dass Sie fortfahren m?chten? Dies ist Ihre letzte Chance, abzubrechen."
},
"unencrypted_conversation_send_failure_toast": "Sie können verschlüsselte Inhalte nicht an verschlüsselte und unverschlüsselte Gespräche senden!",
"native_hooks_send_failure_toast": "Nicht zu senden! Bitte aktivieren Sie Native Hooks in den Einstellungen.",
@@ -2720,13 +2720,13 @@
"incoming_secret_message": "Dein Freund hat deinen öffentlichen Schlüssel akzeptiert. Klicken Sie unten, um das Geheimnis zu akzeptieren."
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"priority_title": "Auto Open Snaps (Priority)",
"title": "Snaps automatisch ?ffnen",
"priority_title": "Snaps automatisch ?ffnen (Priorit?t)",
"error_title": "Auto Open Snaps (Fehler)",
"channel_description": "Notifications for auto-opening Snaps queue status",
"priority_channel_description": "Hohe Prioritätsmeldungen für Auto-Öffnungs-Snaps",
"error_channel_description": "Fehlerbenachrichtigungen, wenn Auto-Öffnung Snaps ausfällt",
"paused_status": "Auto Open Snaps paused",
"paused_status": "Snaps automatisch ?ffnen pausiert",
"processing_status": "Verarbeitung von Schnappern:{queued}in warteschlange,{processed}verarbeitet",
"monitor_status": "Überwachung...",
"recent_snaps": "Neueste Snaps",
@@ -2735,7 +2735,7 @@
"action_clear": "Löschen Sie Ihre Suche",
"action_reset": "Zurück zur Übersicht",
"error_content": "Nicht zu öffnen Snap von{sender}:{error}",
"resumed_feedback": "Auto Open Resumed",
"resumed_feedback": "Automatisches ?ffnen fortgesetzt",
"paused_feedback": "Auto öffnen Pausen",
"resumed_message": "Die Verarbeitung wird automatisch für Queued Snaps fortgesetzt",
"paused_message": "Verarbeitung pausiert. Warteschlangen ({count}schnapper)",
@@ -2759,16 +2759,16 @@
"notification_statistics": "STATISTIK",
"notification_queue_size": "Suchgröße",
"notification_total_opened": "Gesamt Snaps geöffnet",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "WARTESCHLANGE-VORSCHAU",
"notification_processing_continue": "Die Verarbeitung wird automatisch fortgesetzt...",
"notification_no_snaps_queue": "Keine Momente.",
"notification_queue_cleared_opened": "Gelöschte Queue ({opened}geöffnet)",
"content_type_photo_video_snap": "Foto/Video Snap",
"conversation_type_group_with_name": "Gruppe:{name}",
"delete_logs_title": "Logs löschen?",
"delete_logs_title": "Protokolle l?schen?",
"delete_logs_progress": "Löschung{count}logs...",
"delete_logs_description": "Dies wird Protokolle basierend auf dem aktuellen Filter und der Suchanfrage löschen. Diese Aktion kann nicht rückgängig gemacht werden.",
"export_logs_title": "Logs exportieren?",
"export_logs_title": "Protokolle exportieren?",
"export_logs_progress": "Logs exportieren...",
"export_logs_description": "Dies exportiert Protokolle basierend auf dem aktuellen Filter und der Suchanfrage.",
"export_logs_as": "Ausfuhr{type}",
@@ -2830,7 +2830,7 @@
"i_can_see_you": "{friend}tätigkeit{conversation}:{details}"
},
"friend_mutation_observer": {
"notification_channel_name": "Friend Mutation Observer",
"notification_channel_name": "Freund-?nderungsbeobachter",
"friend_removed": "{username}hat dich als freund entfernt",
"birthday_removed": "{username}hat ihren geburtstag entfernt ({birthday})",
"birthday_added": "{username}hat ihren geburtstag hinzugefügt ({birthday})",
@@ -2921,7 +2921,7 @@
},
"tracker": {
"tabs": {
"logs": "Logs",
"logs": "Protokolle",
"rules": "Regeln"
},
"actions": {
@@ -2996,7 +2996,7 @@
"i_can_see_you_entered": "Eingetragen",
"i_can_see_you_left": "Links",
"i_can_see_you_duration": "Dauer",
"i_can_see_you_not_available": "N/A",
"i_can_see_you_not_available": "k. A.",
"i_can_see_you_unit_hour": "h",
"i_can_see_you_unit_minute": "m",
"i_can_see_you_unit_second": "s",
@@ -3078,7 +3078,7 @@
"theme_mode_system": "System",
"theme_mode_light": "Licht",
"theme_mode_dark": "Dunkel",
"test_mode_label": "Enable PurrAura",
"test_mode_label": "PurrAura aktivieren",
"disable_feature_loading_label": "Deaktivieren der Funktion",
"disable_auto_mapper_label": "Auto Mapper deaktivieren",
"disable_bypass_indicator_label": "Bypass Indikator deaktivieren",
@@ -3095,7 +3095,7 @@
"date_range": "Datumsbereich",
"select": "Wählen",
"sort_by_folder": "Sortieren nach Ordner",
"include_my_eyes_only": "Include My Eyes Only",
"include_my_eyes_only": "My Eyes Only einschlie?en",
"cancel": "Abbrechen",
"export": "Ausfuhr",
"quit": "Quitten",
@@ -3158,7 +3158,7 @@
"failed_to_get_file": "Nicht verfügbar",
"download_button": "Downloads",
"chat_attachment": "Anlage{index}",
"log_header_format": "{username}?{type}?{date}",
"log_header_format": "{username} ? {type} ? {date}",
"edited_at_text": "Bearbeitet auf \"{message}\"{date}",
"list_group_format": "Gruppe{name}",
"list_friend_format": "Freund{name}",
@@ -3170,7 +3170,7 @@
},
"debug_dialogs": {
"info": "Info",
"refs": "Refs",
"refs": "Referenzen",
"arroyo": "Arroyo",
"message": "Nachricht",
"media_references": "Medienberichte",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Valitse kieli",
@@ -84,7 +84,7 @@
"clear_button": "Tyhjennä",
"view_logger_history_button": "Näytä lokihistoria",
"ui_settings_title": "Käyttöliittymän asetukset",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptinen palaute",
"use_system_toasts_label": "Käytä järjestelmäpaahtoleipää",
"updates_title": "Päivitykset",
"auto_update_check": "Automaattipäivityksen tarkistus",
@@ -245,14 +245,14 @@
"choose_location_button": "Valitse sijainti",
"manual_coordinates_hint": "Säädä koordinaatit käsin.",
"saved_coordinates_subtitle": "Hallitse tallennettuja parof-paikkoja",
"teleport_to_friend_button": "Teleport to Friend",
"teleport_to_friend_button": "Teleportoi yst?v?n luo",
"spoof_location_toggle": "Spoof- sijainti",
"suspend_location_updates": "Keskeytä sijaintipäivitykset",
"saved_coordinates_title": "Tallennetut koordinaatit",
"no_saved_coordinates_hint": "Ei tallennettuja koordinaatteja",
"delete_dialog_title": "Poista tallennettu koordinaatti",
"delete_dialog_message": "Haluatko varmasti poistaa tämän tallennetun koordinaatin?",
"teleport_to_friend_title": "Teleport to Friend",
"teleport_to_friend_title": "Teleportoi yst?v?n luo",
"search_bar": "Etsi",
"no_friends_map": "Ei ystäviä kartalla",
"no_friends_found": "Ei ystäviä löytynyt"
@@ -595,7 +595,7 @@
},
"actions": {
"clean_snapchat_cache": {
"name": "Clean Snapchat Cache",
"name": "Tyhjenn? Snapchat-v?limuisti",
"description": "Puhdistaa Snapchat välimuistin"
},
"manage_friend_list": {
@@ -1021,7 +1021,7 @@
"friendly": "Ystävällinen",
"humorous": "Huvittavaa",
"empathetic": "Empaattinen",
"toxic": "Edgy",
"toxic": "Rohkea",
"busy": "Kiire"
},
"ai_temperature": {
@@ -1112,7 +1112,7 @@
"translation_position": {
"above": "Tekstin yläpuolella",
"below": "Alla oleva teksti",
"inline": "Inline"
"inline": "Riviss?"
},
"source_language": {
"auto": "Tunnista automaattisesti"
@@ -1139,7 +1139,7 @@
"description": "Aseta paroofoidun sijainnin koordinaatit"
},
"walk_radius": {
"name": "Walk Radius",
"name": "K?velys?de",
"description": "Satunnaisesti kävellä tämän säteen sisällä (ft)"
},
"always_update_location": {
@@ -1173,7 +1173,7 @@
"description": "Ohittaa median lataamisen laadun",
"properties": {
"force_video_upload_source_quality": {
"name": "Force Video Upload Source Quality",
"name": "Pakota videol?hetyksen l?hdelaatu",
"description": "Pakottaa Snapchat käyttämään lähdelaatua ladattaessa videoita\nHuomaa, että tämä ei välttämättä poista metatietoja mediasta"
},
"disable_image_compression": {
@@ -1210,7 +1210,7 @@
"name": "Käyttöliittymän asetukset",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Haptinen palaute"
}
}
},
@@ -1777,7 +1777,7 @@
"description": "API-avain tekoälypalveluun"
},
"ai_system_prompt": {
"name": "AI System Prompt",
"name": "AI-j?rjestelm?kehote",
"description": "Järjestelmäkehotus, joka määrittelee tekoälyn persoonallisuuden ja käyttäytymisen"
},
"ai_max_tokens": {
@@ -1991,7 +1991,7 @@
"description": "Keskeytä käännös, kun palvelu on estetty"
},
"max_retries": {
"name": "Max Retries",
"name": "Maks. uudelleenyritykset",
"description": "Uusintayritysten enimmäismäärä"
},
"retry_delay": {
@@ -2090,7 +2090,7 @@
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"name": "Streaks-muistutus",
"description": "Säännöllisesti ilmoittaa Streaks",
"properties": {
"interval": {
@@ -2112,7 +2112,7 @@
"description": "Kokeelliset ominaisuudet",
"properties": {
"native_hooks": {
"name": "Native Hooks",
"name": "Natiivit hookit",
"description": "Epäturvallisia ominaisuuksia, jotka liittyvät Snapchat'n natiivi-koodiin",
"properties": {
"composer_hooks": {
@@ -2180,7 +2180,7 @@
"description": "Ohita Snapchatiin lähetetty Android-tunnus",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Spoofaa Android ID",
"description": "Ohita Snapchatille lähetetty Android-tunnus omalla arvolla"
},
"custom_android_id": {
@@ -2260,7 +2260,7 @@
"description": "Yritä korjata Friend Feed Menu kuin kun laite on offline se ei voi näyttää oikein"
},
"app_lock": {
"name": "App Lock",
"name": "Sovelluslukko",
"description": "Estää pääsyn Snapchatiin ilman salasanaa",
"properties": {
"lock_on_resume": {
@@ -2270,7 +2270,7 @@
}
},
"infinite_story_boost": {
"name": "Infinite Story Boost",
"name": "Loputon Story-boost",
"description": "Ohita Story Boost Limit -viive"
},
"meo_passcode_bypass": {
@@ -2419,7 +2419,7 @@
"media_download_source": {
"none": "Ei ole",
"pending": "Odotetaan",
"chat_media": "Chat Media",
"chat_media": "Chat-media",
"story": "Tarina",
"public_story": "Julkinen tarina",
"spotlight": "Valokeila",
@@ -2553,7 +2553,7 @@
"deleted": "Poistettu",
"business_accounts": "Yritystilit",
"streaks": "Streakit",
"non_streaks": "Non Streaks",
"non_streaks": "Ilman streakseja",
"followed": "Seuraava",
"following": "Seuraava",
"location_on_map": "Sijainti kartalla"
@@ -2883,7 +2883,7 @@
"translation_position": {
"above": "Yllä",
"below": "Alla",
"inline": "Inline"
"inline": "Riviss?"
},
"language_codes": {
"en": "Englanti",
@@ -3064,7 +3064,7 @@
}
},
"ui_settings_title": "Käyttöliittymän asetukset",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptinen palaute",
"updates_title": "Päivitykset",
"auto_update_check": "Automaattipäivityksen tarkistus",
"update_check_frequency_daily": "Vuorokausi",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Sélectionner la langue",
@@ -182,7 +182,7 @@
"rules_title": "Règles",
"participants_text": "{count}participants",
"not_found": "Non trouvé",
"streaks_title": "Streaks",
"streaks_title": "S?ries",
"streaks_length_text": "Longueur:{length}",
"streaks_expiration_text": "Expire dans{eta}",
"streaks_expiration_text_expired": "Expiré",
@@ -532,11 +532,11 @@
}
},
"auto_open_snaps": {
"name": "Auto Open Snaps",
"name": "Ouverture auto des Snaps",
"description": "Automatiquement ouvre Snaps lors de leur réception",
"options": {
"blacklist": "Exclure de Auto Open Snaps",
"whitelist": "Auto Open Snaps"
"whitelist": "Ouverture auto des Snaps"
}
},
"hide_friend_feed": {
@@ -716,7 +716,7 @@
"notifications": {
"chat_screenshot": "Capture d'écran",
"chat_screen_record": "Enregistrement de l'écran",
"snap_replay": "Snap Replay",
"snap_replay": "Relecture de Snap",
"camera_roll_save": "Roule-photo Enregistrer",
"chat": "Chat",
"chat_reply": "Répondre au chat",
@@ -909,7 +909,7 @@
"disable_confirmation_dialogs": {
"erase_message": "Effacer le message",
"remove_friend": "Supprimer l'ami",
"block_friend": "Block Friend",
"block_friend": "Bloquer l'ami",
"ignore_friend": "Ignorer son ami",
"hide_friend": "Cacher l'ami",
"hide_conversation": "Cacher la conversation",
@@ -1021,7 +1021,7 @@
"friendly": "Amiable",
"humorous": "Humoreux",
"empathetic": "Empathie",
"toxic": "Edgy",
"toxic": "Cinglant",
"busy": "Occupé"
},
"ai_temperature": {
@@ -1543,7 +1543,7 @@
"description": "Saute automatiquement au prochain Snap lors du marquage d'un Snap tel que vu.\nUtilisation en combinaison avec Mark Snap comme Bouton vu"
},
"loop_media_playback": {
"name": "Loop Media Playback",
"name": "Lecture en boucle des m?dias",
"description": "Loops media playing lors de l'affichage Snaps / Stories"
},
"disable_replay_in_ff": {
@@ -1781,7 +1781,7 @@
"description": "Invitation système qui définit la personnalité et le comportement de l'IA"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "Nombre max de tokens IA",
"description": "Nombre maximal de jetons (mots) que l'IA peut utiliser dans les réponses"
},
"ai_temperature": {
@@ -1991,7 +1991,7 @@
"description": "Pause traduction lorsque le service est bloqué"
},
"max_retries": {
"name": "Max Retries",
"name": "Nombre max de tentatives",
"description": "Nombre maximal de tentatives de réessayer"
},
"retry_delay": {
@@ -2552,7 +2552,7 @@
"suggested": "Suggestion",
"deleted": "Supprimé",
"business_accounts": "Comptes d'entreprise",
"streaks": "Streaks",
"streaks": "S?ries",
"non_streaks": "Non streaks",
"followed": "Suivi",
"following": "Suivant",
@@ -2685,7 +2685,7 @@
}
},
"streaks_reminder": {
"notification_title": "Streaks",
"notification_title": "S?ries",
"notification_text": "Vous allez perdre votre Streak avec{friend}en{hoursLeft}heures"
},
"biometric_auth": {
@@ -2720,9 +2720,9 @@
"incoming_secret_message": "Ton ami a accepté ta clé publique. Cliquez ci-dessous pour accepter le secret."
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"title": "Ouverture auto des Snaps",
"priority_title": "Auto Open Snaps (Priorité)",
"error_title": "Auto Open Snaps (Errors)",
"error_title": "Ouverture auto des Snaps (erreurs)",
"channel_description": "Notifications pour l'ouverture automatique du statut de file d'attente snaps",
"priority_channel_description": "Notifications hautement prioritaires pour les snaps d'ouverture automatique",
"error_channel_description": "Notifications d'erreur lorsque l'ouverture automatique des snaps échoue",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OuvrirRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "भाषा चुनें",
@@ -116,7 +116,7 @@
"reset_button": "रीसेट करें",
"done_button": "दान",
"clear_friend_feed": "स्पष्ट मित्र फ़ीड",
"test_mode_label": "Enable PurrAura",
"test_mode_label": "???????? ????? ????",
"disable_feature_loading_label": "अक्षम सुविधा लोड हो रहा है",
"disable_auto_mapper_label": "अक्षम ऑटो मैपर",
"disable_bypass_indicator_label": "निष्क्रिय सूचक"
@@ -347,9 +347,9 @@
"no_repos_added": "कोई प्रस्ताव जोड़ा नहीं",
"add_repo_button": "Repository जोड़ें",
"add_repo_dialog_title": "Repository जोड़ें",
"repo_url_label": "Repository URL",
"repo_url_label": "?????????? URL",
"add_button": "जोड़ें",
"invalid_repo_title": "Invalid Repository",
"invalid_repo_title": "?????? ??????????",
"invalid_repo_error": "यह प्रस्ताव डेटा की आवश्यकता नहीं है।.",
"repo_added_toast": "Repository जोड़ा",
"add_repo_failed_toast": "प्रस्ताव को जोड़ने में विफल:{message}",
@@ -453,9 +453,9 @@
"no_repos_added": "कोई प्रस्ताव जोड़ा नहीं",
"add_repo_button": "Repository जोड़ें",
"add_repo_dialog_title": "Repository जोड़ें",
"repo_url_label": "Repository URL",
"repo_url_label": "?????????? URL",
"add_button": "जोड़ें",
"invalid_repo_title": "Invalid Repository",
"invalid_repo_title": "?????? ??????????",
"invalid_repo_error": "यह प्रस्ताव डेटा की आवश्यकता नहीं है।.",
"repo_added_toast": "Repository जोड़ा",
"add_repo_failed_toast": "प्रस्ताव को जोड़ने में विफल:{message}",
@@ -910,7 +910,7 @@
"erase_message": "संदेश",
"remove_friend": "दोस्त निकालें",
"block_friend": "ब्लॉक मित्र",
"ignore_friend": "Ignore Friend",
"ignore_friend": "????? ?? ?????? ????",
"hide_friend": "मित्र",
"hide_conversation": "छुपाना",
"clear_conversation": "फ्रेंड फ़ीड से वार्तालाप साफ़ करें"
@@ -1021,7 +1021,7 @@
"friendly": "अनुकूल",
"humorous": "विनम्र",
"empathetic": "सहानुभूति",
"toxic": "Edgy",
"toxic": "????",
"busy": "बुसी"
},
"ai_temperature": {
@@ -1429,7 +1429,7 @@
"description": "मित्र फ़ीड से एक विशिष्ट दोस्त को छुपाएं\nइस सुविधा का प्रबंधन करने के लिए सोशल टैब का उपयोग करें"
},
"hide_streak_restore": {
"name": "Hide Streak Restore",
"name": "??????? ??????? ??????",
"description": "मित्र फ़ीड में पुनर्स्थापित बटन छुपाएं"
},
"hide_quick_add_suggestions": {
@@ -1515,11 +1515,11 @@
"description": "किसी को यह जानने से रोकता है कि आपने अपनी कहानी फिर से देख ली है"
},
"hide_peek_a_peek": {
"name": "Hide Peek-a-Peek",
"name": "???-?-??? ??????",
"description": "जब आप एक चैट में आधे स्वाइप करते हैं तो अधिसूचना को भेजे जाने से रोकता है"
},
"hide_bitmoji_presence": {
"name": "Hide Bitmoji Presence",
"name": "??????? ???????? ??????",
"description": "चैट में अपने बिटमोजी को पॉप अप करने से रोकता है"
},
"hide_typing_notifications": {
@@ -1581,7 +1581,7 @@
"description": "कुछ प्रकार के संदेश भेजने से रोकता है"
},
"friend_mutation_notifier": {
"name": "Friend Mutation Notifier",
"name": "?????? ???????? ????",
"description": "जब एक दोस्त की प्रोफ़ाइल में कुछ बदलाव आता है तो आपको सूचित करें"
},
"better_notifications": {
@@ -1609,7 +1609,7 @@
"description": "कई मीडिया संदेशों को एक पाठ अधिसूचना में जोड़ती है जब उन्हें पूर्वावलोकन नहीं किया जा सकता है। चैट पूर्वावलोकन के साथ संयोजन में उपयोग करें"
},
"friend_add_source": {
"name": "Friend Add Source",
"name": "????? ?????? ?? ?????",
"description": "अधिसूचना में एक मित्र अनुरोध का स्रोत दिखाता है"
},
"reply_button": {
@@ -1911,7 +1911,7 @@
"description": "अगर यह विफल रहता है तो एक स्नैप खोलने के लिए समय की संख्या"
},
"retry_delay": {
"name": "Retry Delay (ms)",
"name": "??????? ???? (ms)",
"description": "पुनः प्रयास के बीच मिलीसेकंड में विलंब"
}
}
@@ -1925,7 +1925,7 @@
"description": "ऑटो डिलीट संत संदेश को पृष्ठभूमि में चलाने की अनुमति देता है। ध्यान दें: इससे आपकी बैटरी काफी बढ़ जाएगी"
},
"delete_after_value": {
"name": "Delete After (value)",
"name": "???? ??? ????? (value)",
"description": "भेजे गए संदेश को हटाने से पहले समय मूल्य"
},
"delete_after_unit": {
@@ -1995,7 +1995,7 @@
"description": "पुनः प्रयास की अधिकतम संख्या"
},
"retry_delay": {
"name": "Retry Delay",
"name": "??????? ????",
"description": "Retry प्रयासों (milliseconds) के बीच विलंब"
}
}
@@ -2090,7 +2090,7 @@
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"name": "????????? ????????",
"description": "समय-समय पर आपको अपने स्ट्रेक्स के बारे में सूचित करता है",
"properties": {
"interval": {
@@ -2142,8 +2142,8 @@
}
},
"disable_bitmoji": {
"name": "Disable Bitmoji",
"description": "Disables Friends Profile Bitmoji"
"name": "??????? ????????? ????",
"description": "??????? ?? ????????? ??????? ????????? ???? ??"
},
"custom_emoji_font": {
"name": "कस्टम इमोजी फ़ॉन्ट",
@@ -2180,7 +2180,7 @@
"description": "एंड्रॉइड आईडी को स्नैपचैट में भेजा गया ओवरराइड करें",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Android ID ????? ????",
"description": "एक कस्टम मूल्य के साथ स्नैपचैट को भेजे गए एंड्रॉइड आईडी को ओवरराइड करें"
},
"custom_android_id": {
@@ -2342,7 +2342,7 @@
"description": "स्क्रिप्ट को स्नैपचैट में कस्टम यूआई घटकों को जोड़ने की अनुमति देता है"
},
"disable_log_anonymization": {
"name": "Disable Log Anonymization",
"name": "??? ?????????? ????????? ????",
"description": "लॉग का नामकरण अक्षम करें"
},
"disable_optimization": {
@@ -2665,7 +2665,7 @@
},
"select_attachments_title": "संलग्नक चुनें",
"download_started_toast": "प्रारंभ करना",
"unsupported_content_type_toast": "Unsupported content type!",
"unsupported_content_type_toast": "???????? ??????? ??????!",
"failed_no_longer_available_toast": "मीडिया अब उपलब्ध नहीं",
"no_attachments_toast": "कोई संलग्नक नहीं मिला!",
"already_queued_toast": "पहले से ही कतार में मीडिया!",
@@ -2730,7 +2730,7 @@
"processing_status": "प्रसंस्करण तस्वीरें:{queued}कतार में,{processed}प्रक्रिया",
"monitor_status": "निगरानी...",
"recent_snaps": "हाल ही में स्नैप",
"action_pause": "Pause",
"action_pause": "?????",
"action_resume": "फिर से शुरू",
"action_clear": "स्पष्ट कतार",
"action_reset": "रीसेट करें",
@@ -3078,7 +3078,7 @@
"theme_mode_system": "प्रणाली",
"theme_mode_light": "प्रकाश",
"theme_mode_dark": "अंधेरा",
"test_mode_label": "Enable PurrAura",
"test_mode_label": "???????? ????? ????",
"disable_feature_loading_label": "अक्षम सुविधा लोड हो रहा है",
"disable_auto_mapper_label": "अक्षम ऑटो मैपर",
"disable_bypass_indicator_label": "निष्क्रिय सूचक",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Nyelv kiválasztása",
@@ -80,11 +80,11 @@
"debug_title": "Hibaelhárítás",
"success_toast": "Kész!",
"message_logger_summary": "{messageCount}üzenetek\n{storyCount}történetek",
"export_button": "Export",
"export_button": "Export?l?s",
"clear_button": "Tiszta",
"view_logger_history_button": "A bejelentkezési előzmény megtekintése",
"ui_settings_title": "UI beállítások",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptikus visszajelz?s",
"use_system_toasts_label": "Rendszerköszöntők használata",
"updates_title": "Frissítések",
"auto_update_check": "Automatikus frissítés ellenőrzése",
@@ -134,8 +134,8 @@
},
"features": {
"disabled": "Fogyatékos",
"export_option": "Export",
"import_option": "Import",
"export_option": "Export?l?s",
"import_option": "Import?l?s",
"reset_option": "Újraindítás",
"config_export_success_toast": "Config sikeresen exportált",
"config_import_success_toast": "Sikeresen importált konfig",
@@ -310,7 +310,7 @@
"downloading_script": "Szkript letöltése...",
"edit_module_button": "Szerkesztés",
"enter_url_label": "Adja meg az URL- t",
"import_button": "Import",
"import_button": "Import?l?s",
"import_from_url_button": "Importálás URL-ből",
"import_script_from_url_title": "Szkript importálása URL-ből",
"import_script_warning": "Csak a megbízható forrásból származó szkriptek telepítése.",
@@ -363,7 +363,7 @@
"logs_tab": "Bejelentkezés",
"catalog_button": "Katalógus",
"add_rule_button": "Cikk hozzáadása",
"import_button": "Import",
"import_button": "Import?l?s",
"filters_title": "Szűrők",
"search_by_label": "Keresés",
"newest_first_label": "A következő",
@@ -371,7 +371,7 @@
"until_label": "Amíg",
"unit_label": "Egység",
"pick_a_date_button": "Válasszon időpontot",
"export_button": "Export",
"export_button": "Export?l?s",
"delete_button": "Törlés",
"search_placeholder": "Keresés",
"no_logs_found": "Nem található napló",
@@ -438,7 +438,7 @@
},
"friend_tracker_import": {
"title": "Barát nyomkövető importálása",
"confirm_button": "Import",
"confirm_button": "Import?l?s",
"back_button_description": "Menj vissza",
"expand_button_description": "Kiterjesztés vagy összeomlás kategóriája",
"imported_toast": "Behozott nyomkövető",
@@ -479,7 +479,7 @@
"config_import": {
"title": "Config összefoglaló importálása",
"back_button_description": "Menj vissza",
"confirm_button": "Import",
"confirm_button": "Import?l?s",
"expand_button_description": "Kiterjesztés vagy összeomlás kategóriája",
"enabled": "Engedélyezve",
"disabled": "Fogyatékos",
@@ -702,7 +702,7 @@
"append_type": "A médiatípus hozzáadása a fájlnévhez"
},
"auto_download_sources": {
"friend_snaps": "Friend Snaps",
"friend_snaps": "Bar?t Snaps",
"friend_stories": "Barát történetek",
"public_stories": "Nyilvános történetek",
"spotlight": "Spotlight"
@@ -719,7 +719,7 @@
"snap_replay": "Snap ismétlés",
"camera_roll_save": "Kamera Roll Mentés",
"chat": "Chat",
"chat_reply": "Chat Reply",
"chat_reply": "Chat v?lasz",
"snap": "Snap",
"typing": "Gépelés",
"stories": "Történetek",
@@ -843,7 +843,7 @@
"ORIGINAL": "Eredeti média",
"NOTE": "Hangjegyzet",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"SAVEABLE_SNAP": "Menthet? Snap",
"null": "Snapchat alapértelmezés"
},
"strip_media_metadata": {
@@ -893,12 +893,12 @@
"snapchat_plus": {
"not_subscribed": "Nincs előfizetve",
"basic": "Alap",
"ad_free": "Ad Free",
"ad_free": "Rekl?mmentes",
"null": "Alapértelmezés"
},
"bypass_video_length_restriction": {
"single": "Egységes média",
"split": "Split media",
"split": "M?dia feloszt?sa",
"null": "Alapértelmezés"
},
"old_bitmoji_selfie": {
@@ -909,7 +909,7 @@
"disable_confirmation_dialogs": {
"erase_message": "Üzenet törlése",
"remove_friend": "Barát eltávolítása",
"block_friend": "Block Friend",
"block_friend": "Ismer?s blokkol?sa",
"ignore_friend": "A barát figyelmen kívül hagyása",
"hide_friend": "A barát elrejtése",
"hide_conversation": "A beszélgetés elrejtése",
@@ -1021,7 +1021,7 @@
"friendly": "Barátságos",
"humorous": "Humoros",
"empathetic": "Empatikus",
"toxic": "Edgy",
"toxic": "Provokat?v",
"busy": "Elfoglalt"
},
"ai_temperature": {
@@ -1085,7 +1085,7 @@
"auto_reply_content_types": {
"chat_messages": "Chat üzenetek",
"snap_messages": "Snaps",
"story_share_messages": "Story Shares",
"story_share_messages": "Story megoszt?sok",
"story_reply_messages": "Történet válaszok",
"external_media_messages": "Külső média",
"voice_note_messages": "Hangjegyek",
@@ -1139,7 +1139,7 @@
"description": "Állítsák be a hamis hely koordinátáit"
},
"walk_radius": {
"name": "Walk Radius",
"name": "S?t?l?si sug?r",
"description": "Véletlenszerűen járkáljon ezen a sugáron belül (ft)"
},
"always_update_location": {
@@ -1173,7 +1173,7 @@
"description": "Felülírja a média feltöltési minőségét",
"properties": {
"force_video_upload_source_quality": {
"name": "Force Video Upload Source Quality",
"name": "Vide?felt?lt?s forr?smin?s?g?nek k?nyszer?t?se",
"description": "Forces Snapchat használni a forrás minőségét feltöltésekor videók\nFelhívjuk figyelmét, hogy ez nem távolíthatja el a metaadatokat a médiából"
},
"disable_image_compression": {
@@ -1210,7 +1210,7 @@
"name": "UI beállítások",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Haptikus visszajelz?s"
}
}
},
@@ -1223,7 +1223,7 @@
"description": "Eltávolítja a szakaszokat a Történetek oldaláról\nSzükség lehet egy frissítésre, hogy megfelelően működjön"
},
"block_ads": {
"name": "Block Ads",
"name": "Hirdet?sek blokkol?sa",
"description": "Megelőzi a hirdetések megjelenését"
},
"disable_custom_tabs": {
@@ -1243,7 +1243,7 @@
"description": "Megjeleníti a szerző felhasználónevét a Spotlight megjegyzésekben"
},
"spotlight_comments_username_icon": {
"name": "Spotlight Comments Username Icon",
"name": "Spotlight megjegyz?sek felhaszn?l?n?v ikon",
"description": "Válassza ki, melyik ikon jelenik meg a felhasználónevek mellett a Spotlight megjegyzésekben"
},
"bypass_video_length_restriction": {
@@ -1313,7 +1313,7 @@
"description": "A képek elmentése egy megadott formátumban"
},
"force_voice_note_format": {
"name": "Force Voice Note Format",
"name": "Hangjegyzet form?tum k?nyszer?t?se",
"description": "Forces Voice A megjegyzések elmentése meghatározott formátumban"
},
"auto_download_voice_notes": {
@@ -1399,7 +1399,7 @@
"description": "Megjelenít egy kis preview mellett láthatatlan Snaps a chat"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Bootstrap fel?lb?r?l?s",
"description": "A felhasználói felület bootstrap beállításainak felülbírálása",
"properties": {
"app_appearance": {
@@ -1407,7 +1407,7 @@
"description": "Állandó app megjelenés beállítása"
},
"home_tab": {
"name": "Home Tab",
"name": "Kezd?lap f?l",
"description": "Felülírja a startup lapot a Snapchat megnyitásakor"
}
}
@@ -1445,11 +1445,11 @@
"description": "Válassza ki, melyik UI-komponenst rejtse el"
},
"opera_media_quick_info": {
"name": "Opera Media Quick Info",
"name": "Opera Media gyors inf?",
"description": "Hasznos információkat mutat a médiáról, például a teremtés dátumát az opera nézőinek menüjében"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"name": "R?gi Bitmoji szelfi",
"description": "Visszahozza a Bitmoji selfies régebbi Snapchat verziók"
},
"disable_spotlight": {
@@ -1469,7 +1469,7 @@
"description": "Lehetővé teszi a függőleges történet néző minden történet"
},
"enable_friend_feed_menu_bar": {
"name": "Friend Feed Menu Bar",
"name": "Bar?tfolyam men?sor",
"description": "Engedélyezi az új Friend Feed menü"
},
"message_indicators": {
@@ -1511,7 +1511,7 @@
"description": "Megelőzi, hogy bárki megtudja, hogy láttad a történetét"
},
"prevent_story_rewatch_indicator": {
"name": "Prevent Story Rewatch Indicator",
"name": "Story ?jran?z?s jelz? letilt?sa",
"description": "Megelőzi, hogy bárki megtudja, hogy újra megnézted a sztoriját"
},
"hide_peek_a_peek": {
@@ -1543,7 +1543,7 @@
"description": "Automatikusan ugrik a következő Snap, amikor megjelöli a Snap, mint látható.\nAlkalmazása együtt Mark Snap, mint látott gomb"
},
"loop_media_playback": {
"name": "Loop Media Playback",
"name": "M?dia lej?tsz?s ism?tl?se",
"description": "Loops média lejátszás megtekintésekor Snaps / Stories"
},
"disable_replay_in_ff": {
@@ -1593,15 +1593,15 @@
"description": "Csoportos értesítések egyetlen példányban"
},
"chat_preview": {
"name": "Chat Preview",
"name": "Chat el?n?zet",
"description": "Megjeleníti a kapott üzenetek előnézetét a bejelentésben"
},
"media_preview": {
"name": "Media Preview",
"name": "M?dia el?n?zet",
"description": "Megjeleníti a kiválasztott médiatípusok előnézetét a bejelentésben"
},
"media_caption": {
"name": "Media Caption",
"name": "M?dia felirat",
"description": "Megjeleníti a média csatolását a bejelentésben"
},
"stacked_media_messages": {
@@ -1761,7 +1761,7 @@
"description": "Az MI használata intelligens automatikus válaszok generálásához sablon üzenetek helyett"
},
"ai_provider": {
"name": "AI Provider",
"name": "MI szolg?ltat?",
"description": "Válassza ki, melyik MI szolgáltatást kell használni a válaszok generálására"
},
"ai_endpoint_url": {
@@ -1781,7 +1781,7 @@
"description": "System prompt, amely meghatározza a MI személyiségét és viselkedését"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "MI max tokenek",
"description": "A válaszokban használható MI-k maximális száma (szavak)"
},
"ai_temperature": {
@@ -1895,7 +1895,7 @@
"description": "Lehetővé teszi az Auto Open Snaps futását a háttérben. Megjegyzés: Ez jelentősen lemeríti az akkumulátorát"
},
"min_delay": {
"name": "Min Delay (ms)",
"name": "Min. k?sleltet?s (ms)",
"description": "Minimális késleltetés milliszekundumban a felbontás előtt"
},
"max_delay_ms": {
@@ -1991,7 +1991,7 @@
"description": "A fordítás megszakítása a szolgáltatás blokkolása esetén"
},
"max_retries": {
"name": "Max Retries",
"name": "Max. ?jrapr?b?lkoz?s",
"description": "Az ismételt kísérletek maximális száma"
},
"retry_delay": {
@@ -2094,7 +2094,7 @@
"description": "Időszakos bejelentés a csíkjaidról",
"properties": {
"interval": {
"name": "Interval",
"name": "Intervallum",
"description": "Az egyes emlékeztetők közötti időtartam (óra)"
},
"remaining_hours": {
@@ -2112,11 +2112,11 @@
"description": "Kísérleti jellemzők",
"properties": {
"native_hooks": {
"name": "Native Hooks",
"name": "Native hookok",
"description": "A Snapchat anyavegyületének kódját behálózó biztonsági jellemzők",
"properties": {
"composer_hooks": {
"name": "Composer Hooks",
"name": "Composer hookok",
"description": "Injekciós kód a Composer cross-platform UI keretébe",
"properties": {
"show_first_created_username": {
@@ -2132,7 +2132,7 @@
"description": "Több lehetőséget ad az önmegsemmisítő időzítőre a Snap küldésekor"
},
"composer_console": {
"name": "Composer Console",
"name": "Composer konzol",
"description": "Lehetővé teszi, hogy végrehajtsa a JavaScript kódot a Composer (arm64 csak)"
},
"composer_logs": {
@@ -2204,11 +2204,11 @@
"description": "Konvertálja pattogó chat külső média helyben. Ez jelenik meg a chat letöltési környezet menü"
},
"media_file_picker": {
"name": "Media File Picker",
"name": "M?diaf?jlv?laszt?",
"description": "Lehetővé teszi, hogy válasszon bármilyen videó / audio fájlt a galériából"
},
"story_logger": {
"name": "Story Logger",
"name": "Story napl?",
"description": "A barátok történeteinek története"
},
"call_recorder": {
@@ -2230,7 +2230,7 @@
"description": "Javítja a hangjegy átiratot",
"properties": {
"force_transcription": {
"name": "Force Voice Note Transcription",
"name": "Hangjegyzet-?tirat k?nyszer?t?se",
"description": "Lehetővé teszi az összes hangjegy átírását"
},
"preferred_transcription_lang": {
@@ -2256,7 +2256,7 @@
"description": "Nem kiadott / béta Snapchat funkciók engedélyezése"
},
"context_menu_fix": {
"name": "Context Menu Fix",
"name": "Kontextusmen? jav?t?s",
"description": "A Friend Feed Menü javítása, mint amikor a készülék kikapcsolt, nem jelenik meg helyesen"
},
"app_lock": {
@@ -2274,7 +2274,7 @@
"description": "A Story Boost Limit késleltetése"
},
"meo_passcode_bypass": {
"name": "My Eyes Only Passcode Bypass",
"name": "My Eyes Only jelsz? megker?l?se",
"description": "A Szemeim megkerülése Csak jelszó\nEz csak akkor fog működni, ha a jelszó helyesen van bejegyezve, mielőtt"
},
"no_friend_score_delay": {
@@ -2407,7 +2407,7 @@
"MAP_REACTION": "Térkép Reakció",
"chat_messages": "Chat üzenetek",
"snap_messages": "Snaps",
"story_share_messages": "Story Shares",
"story_share_messages": "Story megoszt?sok",
"story_reply_messages": "Történet válaszok",
"external_media_messages": "Külső média",
"voice_note_messages": "Hangjegy",
@@ -2419,12 +2419,12 @@
"media_download_source": {
"none": "Nincs",
"pending": "Függőben",
"chat_media": "Chat Media",
"chat_media": "Chat m?dia",
"story": "Történet",
"public_story": "Nyilvános történet",
"spotlight": "Spotlight",
"profile_picture": "Profilkép",
"story_logger": "Story Logger",
"story_logger": "Story napl?",
"message_logger": "Üzenetjelző",
"merged": "Egyesülve",
"voice_call": "Hanghívás"
@@ -2454,7 +2454,7 @@
"ORIGINAL": "Eredeti média",
"NOTE": "Hangjegyzet",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"SAVEABLE_SNAP": "Menthet? Snap",
"null": "Snapchat alapértelmezés",
"multiple_media_toast": "Egyszerre csak egy médiát küldhetsz"
},
@@ -2514,7 +2514,7 @@
"actions": {
"remove_friends": "A barátok eltávolítása",
"clear_conversations": "Beszélgetések törlése",
"clear_friend_feed": "Clear Friend Feed ({count})",
"clear_friend_feed": "Bar?tfolyam t?rl?se ({count})",
"unfollow": "Nem követi",
"remove": "Eltávolítás"
},
@@ -2580,7 +2580,7 @@
"download_medias_title": "Média letöltése"
},
"dialog_negative_button": "Törlés",
"dialog_positive_button": "Export",
"dialog_positive_button": "Export?l?s",
"exported_to": "Kivitel{path}",
"exporting_chats": "Chats exportálása...",
"processing_chats": "Felfeldolgozás{amount}beszélgetések...",
@@ -2627,7 +2627,7 @@
},
"cleared_from_feed": "Takarmányból megtisztítva",
"tracker_actions": {
"log": "Log",
"log": "Napl?",
"in_app_notification": "Az alkalmazás bejelentése",
"push_notification": "Értesítés",
"custom": "Egyéni"
@@ -2721,7 +2721,7 @@
},
"auto_open_snaps": {
"title": "Automatikus nyitás",
"priority_title": "Auto Open Snaps (Priority)",
"priority_title": "Snaps automatikus megnyit?sa (priorit?s)",
"error_title": "Auto Open Snaps (hibák)",
"channel_description": "Értesítések az automatikus nyitási csatolású csatolások sorban állásáról",
"priority_channel_description": "Az automatikus nyitásra vonatkozó kiemelt értesítések",
@@ -2751,7 +2751,7 @@
"unknown_user": "Ismeretlen felhasználó",
"content_type_external_media": "Külső média",
"content_type_snap": "Snap",
"conversation_type_friend_dm": "Friend DM",
"conversation_type_friend_dm": "Bar?t DM",
"conversation_type_dm": "DM",
"conversation_type_group_chat": "Csoport",
"conversation_type_chat": "Chat",
@@ -2925,7 +2925,7 @@
"rules": "Szabályok"
},
"actions": {
"export": "Export",
"export": "Export?l?s",
"delete": "Törlés",
"add_rule": "Cikk hozzáadása",
"save_rule": "A szabály mentése"
@@ -3053,7 +3053,7 @@
"config_json": "Konfigurációs fájl",
"mappings_json": "Mappings fájl",
"message_logger_db": "Üzenet bejelentkezési adatbázis",
"pinned_best_friend_txt": "Pinned Best Friend File",
"pinned_best_friend_txt": "Kit?z?tt legjobb bar?t f?jl",
"native_sig_cache_txt": "Natív aláírás Cache fájl"
},
"settings": {
@@ -3064,7 +3064,7 @@
}
},
"ui_settings_title": "UI beállítások",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptikus visszajelz?s",
"updates_title": "Frissítések",
"auto_update_check": "Automatikus frissítés ellenőrzése",
"update_check_frequency_daily": "Napi",
@@ -3097,7 +3097,7 @@
"sort_by_folder": "Rendezés mappa szerint",
"include_my_eyes_only": "Csak a szemeimet tartalmazza",
"cancel": "Törlés",
"export": "Export",
"export": "Export?l?s",
"quit": "Kilépés",
"done": "Kész",
"ok": "OKÉ",
@@ -3111,7 +3111,7 @@
"import_script_from_url": "Szkript importálása URL-ből",
"warning_imported_scripts": "Figyelem: Az importált szkriptek károsak lehetnek az eszközre. Csak megbízható forrásból származó forgatókönyveket importálunk.",
"enter_url_here": "Adja meg az URL-t:",
"import": "Import",
"import": "Import?l?s",
"cancel": "Törlés",
"documentation": "Dokumentáció"
},
@@ -3170,7 +3170,7 @@
},
"debug_dialogs": {
"info": "Információ",
"refs": "Refs",
"refs": "Referenci?k",
"arroyo": "Arroyo",
"message": "Üzenet",
"media_references": "Médiahivatkozások",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Pilih Bahasa",
@@ -52,7 +52,7 @@
"customize_bottom_bar_subtitle": "Pilih tab mana yang ditampilkan pada layar rumah Anda",
"available_tabs_title": "Tab Tersedia",
"shown_tabs_title": "Tab Shown",
"reset_button": "Reset",
"reset_button": "Atur ulang",
"done_button": "Selesai"
},
"sections": {
@@ -84,13 +84,13 @@
"clear_button": "Hapus",
"view_logger_history_button": "Tilik Riwayat Logger",
"ui_settings_title": "Pengaturan UI",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Umpan balik haptik",
"use_system_toasts_label": "Gunakan Kaus Sistem",
"updates_title": "Pemutakhiran",
"auto_update_check": "Periksa Perbarui Otomatis",
"update_check_frequency_daily": "Harian",
"update_check_frequency_weekly": "Mingguan",
"update_check_frequency_monthly": "Monthly",
"update_check_frequency_monthly": "Bulanan",
"update_channel_stable": "Stabil",
"update_channel_prerelease": "Lepaskan",
"update_notification_channel_name": "Pemutakhiran",
@@ -107,13 +107,13 @@
"friend_notes_no_notes_to_backup": "Belum ada catatan cadangan",
"friend_notes_backup_success": "Catatan teman didukung",
"friend_notes_restore_success": "Catatan teman dipulihkan",
"backup_button": "Backup",
"backup_button": "Cadangan",
"restore_button": "Pulihkan",
"view_button": "Tilik",
"customize_bottom_bar_title": "Ubahan Batang Bawah",
"customize_bottom_bar_subtitle": "Pilih tab mana yang ditampilkan pada layar rumah Anda",
"available_tabs_title": "Tab Tersedia",
"reset_button": "Reset",
"reset_button": "Atur ulang",
"done_button": "Selesai",
"clear_friend_feed": "Hapus Pakan Teman",
"test_mode_label": "Aktifkan PurrAura",
@@ -136,7 +136,7 @@
"disabled": "Dinonaktifkan",
"export_option": "Ekspor",
"import_option": "Impor",
"reset_option": "Reset",
"reset_option": "Atur ulang",
"config_export_success_toast": "Konfigurasi diekspor dengan sukses",
"config_import_success_toast": "Konfigurasi diimpor dengan sukses",
"config_import_failure_toast": "Gagal mengimpor konfigurasi{error}",
@@ -369,7 +369,7 @@
"newest_first_label": "Terbaru pertama",
"since_label": "Sejak",
"until_label": "Sampai",
"unit_label": "Unit",
"unit_label": "Satuan",
"pick_a_date_button": "Pilih tanggal",
"export_button": "Ekspor",
"delete_button": "Hapus",
@@ -385,7 +385,7 @@
"rule_name_label": "Nama Aturan",
"default_rule_name": "Aturan Baru",
"author_name_label": "Penulis",
"scope_section_title": "Scope",
"scope_section_title": "Lingkup",
"scope_all": "Semua Teman / Grup",
"scope_whitelist": "Tidak ada kecuali",
"scope_blacklist": "Semua orang kecuali",
@@ -394,7 +394,7 @@
"no_events_text": "Belum ada kejadian yang ditambahkan",
"add_event_dialog_title": "Tambah Peristiwa",
"event_type_label": "Jenis Kejadian",
"triggers_title": "Triggers",
"triggers_title": "Pemicu",
"conditions_title": "Kondisi",
"condition_only_inside_conversation": "Hanya ketika aku di dalam percakapan",
"condition_only_outside_conversation": "Hanya ketika aku di luar percakapan",
@@ -511,7 +511,7 @@
"name": "Mode Stealth",
"description": "Mencegah siapa pun dari mengetahui Anda telah membuka Snaps mereka / Chats dan percakapan",
"options": {
"blacklist": "Exclude from Stealth Mode",
"blacklist": "Kecualikan dari mode siluman",
"whitelist": "Mode siluman"
}
},
@@ -608,7 +608,7 @@
},
"export_memories": {
"name": "Ekspor Kenangan",
"description": "Exports memories into a ZIP file"
"description": "Ekspor Memories ke file ZIP"
},
"bulk_messaging_action": {
"name": "Aksi Pesan Bolak",
@@ -628,7 +628,7 @@
},
"friend_tracker": {
"name": "Pelacak Teman",
"description": "Track your friends on Snapchat"
"description": "Lacak teman Anda di Snapchat"
},
"logger_history": {
"name": "Riwayat Logger",
@@ -716,7 +716,7 @@
"notifications": {
"chat_screenshot": "Cuplikan layar",
"chat_screen_record": "Catatan Layar",
"snap_replay": "Snap Replay",
"snap_replay": "Putar ulang Snap",
"camera_roll_save": "Kamera Roll Save",
"chat": "Obrolan",
"chat_reply": "Balas Percakapan",
@@ -733,43 +733,43 @@
"map_live_location": "Lokasi Peta Hidup"
},
"auto_read": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"hide_typing_indicator": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"auto_delete_sent_messages": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"auto_download": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"message_logger": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"auto_reply": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Daftar hitam",
"whitelist": "Daftar putih",
"disabled": "Dinonaktifkan"
},
"custom_android_id": {
@@ -1021,7 +1021,7 @@
"friendly": "Ramah",
"humorous": "Humor",
"empathetic": "Empatik",
"toxic": "Edgy",
"toxic": "Kasar",
"busy": "Sibuk"
},
"ai_temperature": {
@@ -1086,7 +1086,7 @@
"chat_messages": "Pesan Percakapan",
"snap_messages": "Snaps",
"story_share_messages": "Tabungan Cerita",
"story_reply_messages": "Story Replies",
"story_reply_messages": "Balasan Story",
"external_media_messages": "Media Eksternal",
"voice_note_messages": "Catatan Suara",
"sticker_messages": "Stiker",
@@ -1112,7 +1112,7 @@
"translation_position": {
"above": "Di atas teks",
"below": "Di bawah teks",
"inline": "Inline"
"inline": "Sebaris"
},
"source_language": {
"auto": "Mendeteksi otomatis"
@@ -1210,7 +1210,7 @@
"name": "Pengaturan UI",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Umpan balik haptik"
}
}
},
@@ -1337,11 +1337,11 @@
"description": "Nyatakan tambahan Pilihan FFmpeg",
"properties": {
"threads": {
"name": "Threads",
"name": "Utas",
"description": "Jumlah thread yang dipakai"
},
"preset": {
"name": "Preset",
"name": "Prasetel",
"description": "Set kecepatan konversi"
},
"constant_rate_factor": {
@@ -1367,7 +1367,7 @@
}
},
"logging": {
"name": "Logging",
"name": "Pencatatan",
"description": "Tampilkan toast ketika media mengunduh"
},
"custom_path_format": {
@@ -1389,7 +1389,7 @@
"description": "Tampilkan pratilik pesan terakhir dalam Pangsa Teman",
"properties": {
"amount": {
"name": "Amount",
"name": "Jumlah",
"description": "Jumlah pesan yang akan ditampilkan"
}
}
@@ -1399,7 +1399,7 @@
"description": "Tampilkan pratinjau kecil di samping Snaps tak terlihat dalam percakapan"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Penggantian bootstrap",
"description": "Timpa pengaturan bootstrap antar muka pengguna",
"properties": {
"app_appearance": {
@@ -1507,7 +1507,7 @@
"description": "Mencegah Snapchat mendeteksi ketika Anda mengambil cuplikan layar"
},
"anonymous_story_viewing": {
"name": "Anonymous Story Viewing",
"name": "Melihat Story secara anonim",
"description": "Mencegah siapa pun dari mengetahui Anda telah melihat cerita mereka"
},
"prevent_story_rewatch_indicator": {
@@ -1617,7 +1617,7 @@
"description": "Tambahkan tombol balasan ke pemberitahuan"
},
"smart_replies": {
"name": "Smart Replies",
"name": "Balasan pintar",
"description": "Tambahkan balasan yang disarankan ke pemberitahuan (Android 10 +). Gunakan dalam kombinasi dengan Tombol Balas"
},
"download_button": {
@@ -1693,7 +1693,7 @@
"description": "Membuat catatan audio tidak dapat diselamatkan"
},
"story_reply": {
"name": "Story Replies",
"name": "Balasan Story",
"description": "Membuat cerita menjawab tak terbalas"
}
}
@@ -1713,7 +1713,7 @@
}
},
"strip_media_metadata": {
"name": "Strip Media Metadata",
"name": "Hapus metadata media",
"description": "Hapus metadata media sebelum mengirim pesan"
},
"bypass_message_retention_policy": {
@@ -1847,11 +1847,11 @@
"description": "Pesan jawaban otomatis untuk pesan obrolan teks"
},
"snap_messages": {
"name": "Snap Replies",
"name": "Balasan Snap",
"description": "Pesan jawaban otomatis untuk terkunci"
},
"story_share_messages": {
"name": "Story Share Replies",
"name": "Balasan berbagi Story",
"description": "Pesan jawaban otomatis untuk berbagi cerita"
},
"story_reply_messages": {
@@ -1867,11 +1867,11 @@
"description": "Pesan jawaban otomatis untuk catatan suara"
},
"sticker_messages": {
"name": "Sticker Replies",
"name": "Balasan stiker",
"description": "Pesan jawaban otomatis bagi stiker"
},
"tiny_snap_messages": {
"name": "Tiny Snap Replies",
"name": "Balasan Snap kecil",
"description": "Pesan jawaban otomatis untuk kecil terkunci"
},
"map_reaction_messages": {
@@ -2090,7 +2090,7 @@
}
},
"streaks_reminder": {
"name": "Streaks Reminder",
"name": "Pengingat streak",
"description": "Secara periodikal memberitahu Anda tentang Streaks Anda",
"properties": {
"interval": {
@@ -2128,7 +2128,7 @@
"description": "Meningkatkan jumlah media maksimum Anda dapat mengirim dari gulungan kamera"
},
"custom_self_destruct_snap_delay": {
"name": "Custom Self Destruct Snap Delay",
"name": "Penundaan Snap hancur sendiri khusus",
"description": "Memberikan lebih banyak pilihan untuk timer penghancuran diri sendiri ketika mengirim Snap"
},
"composer_console": {
@@ -2177,7 +2177,7 @@
},
"spoof_device_id": {
"name": "ID Perangkat Spoof",
"description": "Override the Android ID sent to Snapchat",
"description": "Ganti ID Android yang dikirim ke Snapchat",
"properties": {
"spoof_android_id": {
"name": "ID Android Spoof",
@@ -2264,7 +2264,7 @@
"description": "Mencegah akses ke Snapchat tanpa kode akses",
"properties": {
"lock_on_resume": {
"name": "Lock On Resume",
"name": "Kunci saat dilanjutkan",
"description": "Kunci aplikasi ketika dibuka kembali"
}
}
@@ -2274,7 +2274,7 @@
"description": "Melewati penundaan Batas Boost Cerita"
},
"meo_passcode_bypass": {
"name": "My Eyes Only Passcode Bypass",
"name": "Lewati kode sandi My Eyes Only",
"description": "Melewati Mataku Hanya kode sandi\nIni hanya akan bekerja jika kode akses telah dimasukkan dengan benar sebelum"
},
"no_friend_score_delay": {
@@ -2282,7 +2282,7 @@
"description": "Hapus penundaan ketika melihat Skor Teman"
},
"best_friend_pinning": {
"name": "Best Friend Pinning",
"name": "Penyematan teman terbaik",
"description": "Memungkinkan Anda untuk pin teman sebagai nomor satu teman terbaik Anda. Catatan: Hanya Anda dapat melihat teman terbaik Anda terjepit"
},
"e2ee": {
@@ -2401,18 +2401,18 @@
"FAMILY_CENTER_INVITE": "Undang Pusat Keluarga",
"FAMILY_CENTER_ACCEPT": "Family Center Terima",
"FAMILY_CENTER_LEAVE": "Meninggalkan Pusat Keluarga",
"STATUS_PLUS_GIFT": "Status Plus Gift",
"TINY_SNAP": "Tiny Snap",
"STATUS_PLUS_GIFT": "Hadiah Status Plus",
"TINY_SNAP": "Snap kecil",
"STATUS_COUNTDOWN": "Hitung mundur",
"MAP_REACTION": "Reaksi Peta",
"chat_messages": "Pesan Percakapan",
"snap_messages": "Snaps",
"story_share_messages": "Tabungan Cerita",
"story_reply_messages": "Story Replies",
"story_reply_messages": "Balasan Story",
"external_media_messages": "Media Eksternal",
"voice_note_messages": "Catatan Suara",
"sticker_messages": "Sticker",
"tiny_snap_messages": "Tiny Snap",
"tiny_snap_messages": "Snap kecil",
"map_reaction_messages": "Reaksi Peta",
"half_swipes": "Setengah Berenang"
},
@@ -2493,7 +2493,7 @@
"not_subscribed": "Tidak Berlangganan"
},
"friendship_link_type": {
"mutual": "Mutual",
"mutual": "Saling",
"outgoing": "Keluar",
"blocked": "Diblokir",
"deleted": "Dihapus",
@@ -2553,7 +2553,7 @@
"deleted": "Dihapus",
"business_accounts": "Akun Bisnis",
"streaks": "Streaks",
"non_streaks": "Non Streaks",
"non_streaks": "Tanpa streak",
"followed": "Diikuti",
"following": "Mengikuti",
"location_on_map": "Lokasi pada Peta"
@@ -2582,7 +2582,7 @@
"dialog_negative_button": "Batal",
"dialog_positive_button": "Ekspor",
"exported_to": "Exported to{path}",
"exporting_chats": "Exporting Chats...",
"exporting_chats": "Mengekspor chat...",
"processing_chats": "Proses{amount}percakapan...",
"export_fail": "Gagal mengekspor percakapan{conversation}",
"writing_output": "Menulis keluaran...",
@@ -2622,12 +2622,12 @@
"snap_replayed": "Snap Diputar Ulang",
"snap_replayed_twice": "Snap Replayed Dua kali",
"snap_screenshot": "Cuplikan Snap",
"snap_screen_record": "Snap Screen Record",
"i_can_see_you": "I Can See You"
"snap_screen_record": "Rekam layar Snap",
"i_can_see_you": "Aku bisa melihatmu"
},
"cleared_from_feed": "Dibersihkan dari pakan",
"tracker_actions": {
"log": "Log",
"log": "Catat",
"in_app_notification": "Pemberitahuan In- App",
"push_notification": "Pemberitahuan Push",
"custom": "Gubahan"
@@ -2759,7 +2759,7 @@
"notification_statistics": "STATIK",
"notification_queue_size": "Ukuran Antrian",
"notification_total_opened": "Total Snaps dibuka",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "PRATINJAU ANTRIAN",
"notification_processing_continue": "Proses akan dilanjutkan secara otomatis...",
"notification_no_snaps_queue": "Tidak terkunci dalam antrian.",
"notification_queue_cleared_opened": "Antrian dibersihkan ({opened}dibuka)",
@@ -2883,7 +2883,7 @@
"translation_position": {
"above": "Atas",
"below": "Bawah",
"inline": "Inline"
"inline": "Sebaris"
},
"language_codes": {
"en": "Inggris",
@@ -2915,7 +2915,7 @@
"et": "Estonia",
"lv": "Latvia",
"lt": "Lithuania",
"mt": "Maltese",
"mt": "Malta",
"ga": "Irlandia",
"cy": "Welsh"
},
@@ -3028,11 +3028,11 @@
},
"edit_rule": {
"custom_rule": "Aturan Gubahan",
"scope": "Scope",
"scope": "Lingkup",
"events": "Kejadian",
"add_event": "Tambah Peristiwa",
"type": "Tipe",
"triggers": "Triggers",
"triggers": "Pemicu",
"conditions": "Kondisi",
"only_inside_conversation": "Hanya ketika aku di dalam percakapan",
"only_outside_conversation": "Hanya ketika aku di luar percakapan",
@@ -3064,12 +3064,12 @@
}
},
"ui_settings_title": "Pengaturan UI",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Umpan balik haptik",
"updates_title": "Pemutakhiran",
"auto_update_check": "Periksa Perbarui Otomatis",
"update_check_frequency_daily": "Harian",
"update_check_frequency_weekly": "Mingguan",
"update_check_frequency_monthly": "Monthly",
"update_check_frequency_monthly": "Bulanan",
"update_channel_stable": "Stabil",
"update_channel_prerelease": "Lepaskan",
"friend_notes_title": "Catatan Teman",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Seleziona la lingua",
@@ -30,10 +30,10 @@
"home": "Home",
"home_about": "Informazioni",
"home_settings": "Impostazioni impostazioni",
"home_logs": "Logs",
"home_logs": "Registri",
"logger_history": "Storia di Logger",
"logged_stories": "Storie intasate",
"friend_tracker": "Friend Tracker",
"friend_tracker": "Tracker amici",
"friend_tracker_catalog": "Catalogo Tracker Friend",
"manage_friend_tracker_repos": "Gestire i repository di Friend Tracker",
"edit_rule": "Modifica",
@@ -42,7 +42,7 @@
"social": "Sociale",
"manage_scope": "Gestisci lo Scope",
"messaging_preview": "Anteprima",
"scripts": "Scripts",
"scripts": "Script",
"manage_script_repos": "Gestione dei repository Script",
"view_logger_history": "Storia di Logger",
"better_location": "Migliore posizione"
@@ -123,7 +123,7 @@
},
"tasks": {
"no_tasks": "Nessuna attività",
"merge_button": "Merge",
"merge_button": "Unisci",
"failed_to_open_file": "Non ha aperto il file",
"merge_files_toast": "Merito{count}file",
"remove_selected_tasks_title": "Sei sicuro di voler rimuovere le attività selezionate?",
@@ -276,7 +276,7 @@
"content": "PurrfectSnap include uno strumento di scripting, consentendo l'esecuzione del codice definito dall'utente sul dispositivo. Utilizzare estrema cautela e installare solo moduli da fonti conosciute e affidabili. I moduli non autorizzati o non verificati possono costituire rischi di sicurezza per il sistema."
},
"reset_config": {
"title": "Reset config",
"title": "Reimposta configurazione",
"content": "Sei sicuro di voler resettare la configurazione?",
"success_toast": "Ripristino configurazione con successo"
},
@@ -305,7 +305,7 @@
"clear_module_data_failed": "Non riuscita a cancellare i dati del modulo",
"delete_module_button": "Cancella",
"delete_module_failed": "Non è stato possibile eliminare il modulo",
"documentation_button": "Docs",
"documentation_button": "Documentazione",
"download_script_failed": "Non è riuscito a scaricare lo script",
"downloading_script": "Scarica lo script...",
"edit_module_button": "Modifica",
@@ -360,7 +360,7 @@
},
"friend_tracker": {
"rules_tab": "Regole",
"logs_tab": "Logs",
"logs_tab": "Registri",
"catalog_button": "Catalogo",
"add_rule_button": "Aggiungere la regola",
"import_button": "Importazioni",
@@ -394,7 +394,7 @@
"no_events_text": "Nessun evento aggiunto",
"add_event_dialog_title": "Aggiungi evento",
"event_type_label": "Tipo Evento",
"triggers_title": "Triggers",
"triggers_title": "Trigger",
"conditions_title": "Condizioni",
"condition_only_inside_conversation": "Solo quando sono dentro la conversazione",
"condition_only_outside_conversation": "Solo quando sono fuori a parlare",
@@ -500,7 +500,7 @@
},
"properties": {
"auto_download": {
"name": "Auto download",
"name": "Download automatico",
"description": "Scaricare automaticamente Snaps quando li visualizza",
"options": {
"blacklist": "Escluso da Auto Download",
@@ -532,11 +532,11 @@
}
},
"auto_open_snaps": {
"name": "Auto Open Snaps",
"name": "Apri Snaps automaticamente",
"description": "Apre automaticamente Snaps quando li riceve",
"options": {
"blacklist": "Escluso da Auto Open Snaps",
"whitelist": "Auto Open Snaps"
"whitelist": "Apri Snaps automaticamente"
}
},
"hide_friend_feed": {
@@ -627,7 +627,7 @@
"description": "Importa i file da usare in Snapchat"
},
"friend_tracker": {
"name": "Friend Tracker",
"name": "Tracker amici",
"description": "Traccia i tuoi amici su Snapchat"
},
"logger_history": {
@@ -659,7 +659,7 @@
"null": "Utilizzare il livello reale della batteria"
},
"friend_feed_menu_buttons": {
"auto_download": "⬇️ Auto Download",
"auto_download": "?? Download automatico",
"auto_save": "💬 Salva automaticamente i messaggi",
"unsaveable_messages": "⬇️ Messaggi non rivelabili",
"auto_open_snaps": "📷 Auto a scatto aperto",
@@ -671,7 +671,7 @@
"conversation_info": "Informazioni sulla conversazione",
"e2e_encryption": "🔒 Utilizzare la crittografia E2E",
"message_logger": "Traduzione:",
"auto_read": "✅ Auto Read",
"auto_read": "? Lettura automatica",
"hide_typing_indicator": "🙈 Nascondi Indicatore di digitazione"
},
"schedule_scheduled_for": "Programmato per{name}in{time}",
@@ -734,42 +734,42 @@
},
"auto_read": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"hide_typing_indicator": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"auto_delete_sent_messages": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"auto_download": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"stealth": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"auto_save": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"message_logger": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"auto_reply": {
"blacklist": "Lista nera",
"whitelist": "Whitelist",
"whitelist": "Lista consentiti",
"disabled": "Disabili"
},
"custom_android_id": {
@@ -792,7 +792,7 @@
"null": "Utilizzare Snapchat predefinito"
},
"custom_emoji_font": {
"null": "Default Emoji Font"
"null": "Font emoji predefinito"
},
"custom_shared_library": {
"null": "Utilizzare la libreria predefinita"
@@ -888,7 +888,7 @@
"null": "Automatico"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatico"
},
"snapchat_plus": {
"not_subscribed": "Non sottoscritto",
@@ -909,7 +909,7 @@
"disable_confirmation_dialogs": {
"erase_message": "Cancellare il messaggio",
"remove_friend": "Rimuovi il tuo amico",
"block_friend": "Block Friend",
"block_friend": "Blocca amico",
"ignore_friend": "Ignore amico",
"hide_friend": "Nascondi amico",
"hide_conversation": "Nascondi Conversazione",
@@ -917,7 +917,7 @@
},
"edit_text_override": {
"multi_line_chat_input": "Linea Multi Input di chat",
"bypass_text_input_limit": "Bypass Text Input Limit"
"bypass_text_input_limit": "Bypass limite input testo"
},
"auto_purge": {
"never": "Mai",
@@ -954,7 +954,7 @@
"read_media_video": "Leggi i media Video",
"camera": "Macchina fotografica",
"microphone": "Microfono",
"location": "Location",
"location": "Posizione",
"read_contacts": "Leggi i contatti",
"nearby_devices": "Dispositivi vicini",
"phone_calls": "Chiamate telefoniche"
@@ -1019,16 +1019,16 @@
"casual": "Casuale",
"formal": "Forma",
"friendly": "Amichevole",
"humorous": "Humorous",
"empathetic": "Empathetic",
"toxic": "Edgy",
"humorous": "Spiritoso",
"empathetic": "Empatico",
"toxic": "Tagliente",
"busy": "Occupato"
},
"ai_temperature": {
"0.7": "Equilibrato (0.7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatico",
"en": "Inglese",
"es": "Spagnolo",
"fr": "Francese",
@@ -1090,7 +1090,7 @@
"external_media_messages": "Supporti esterni",
"voice_note_messages": "Note vocali",
"sticker_messages": "Adesivi",
"tiny_snap_messages": "Tiny Snaps",
"tiny_snap_messages": "Snaps piccoli",
"map_reaction_messages": "Reazioni della mappa",
"half_swipes": "Mezzo Swipes"
},
@@ -1170,7 +1170,7 @@
},
"media_upload_quality": {
"name": "Qualità di caricamento dei media",
"description": "Overrides the media upload quality",
"description": "Sovrascrive la qualit? di caricamento dei media",
"properties": {
"force_video_upload_source_quality": {
"name": "Forza Video Upload Sorgente Qualità",
@@ -1255,7 +1255,7 @@
"description": "Imposta la velocità predefinita per la riproduzione di video\nIl valore deve essere tra 0,1 e 4.0"
},
"video_playback_rate_slider": {
"name": "Video Playback Rate Slider",
"name": "Slider velocit? riproduzione video",
"description": "Aggiunge un cursore nel menu contestuale dell'opera per cambiare la velocità di riproduzione video\nNota: Le modifiche si applicano solo ai video successivi"
},
"disable_google_play_dialogs": {
@@ -1341,7 +1341,7 @@
"description": "La quantità di fili da usare"
},
"preset": {
"name": "Preset",
"name": "Preimpostazione",
"description": "Impostare la velocità della conversione"
},
"constant_rate_factor": {
@@ -1349,7 +1349,7 @@
"description": "Impostare il fattore di tasso costante per l'encoder video\nDa 0 a 51 per libx264"
},
"video_bitrate": {
"name": "Video Bitrate",
"name": "Bitrate video",
"description": "Impostare il bitrate video (kbps)"
},
"audio_bitrate": {
@@ -1399,7 +1399,7 @@
"description": "Visualizza una piccola anteprima accanto a unseen Snaps in chat"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Override Bootstrap",
"description": "Sovrascrive le impostazioni dell'interfaccia utente",
"properties": {
"app_appearance": {
@@ -1407,7 +1407,7 @@
"description": "Imposta un aspetto persistente"
},
"home_tab": {
"name": "Home Tab",
"name": "Scheda Home",
"description": "Sovrascrive la scheda di avvio quando si apre Snapchat"
}
}
@@ -1449,7 +1449,7 @@
"description": "Mostra informazioni utili sui media come la data di creazione nel menu contestuale del visualizzatore d'opera"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"name": "Vecchio selfie Bitmoji",
"description": "Riporta i selfie Bitmoji dalle versioni precedenti di Snapchat"
},
"disable_spotlight": {
@@ -1469,7 +1469,7 @@
"description": "Abilita lo spettatore di storia verticale per tutte le storie"
},
"enable_friend_feed_menu_bar": {
"name": "Friend Feed Menu Bar",
"name": "Barra menu del feed amici",
"description": "Abilita il nuovo Friend Feed Menu Bar"
},
"message_indicators": {
@@ -1499,11 +1499,11 @@
}
},
"messaging": {
"name": "Messaging",
"name": "Messaggistica",
"description": "Cambia come interagire con gli amici",
"properties": {
"bypass_screenshot_detection": {
"name": "Bypass Screenshot Detection",
"name": "Bypass rilevamento screenshot",
"description": "Impedisce Snapchat di rilevare quando si prende uno screenshot"
},
"anonymous_story_viewing": {
@@ -1543,7 +1543,7 @@
"description": "Salta automaticamente al prossimo Snap quando si marca uno Snap come visto.\nUtilizzare in combinazione con Mark Snap come pulsante di visualizzazione"
},
"loop_media_playback": {
"name": "Loop Media Playback",
"name": "Riproduzione media in loop",
"description": "Loops riproduzione media quando si visualizza Snaps / Stories"
},
"disable_replay_in_ff": {
@@ -1647,7 +1647,7 @@
"description": "Impedisce ai propri messaggi di essere cancellati"
},
"auto_purge": {
"name": "Auto Purge",
"name": "Pulizia automatica",
"description": "Elimina automaticamente i messaggi memorizzati nella cache che sono più vecchi della quantità di tempo specificata"
},
"message_filter": {
@@ -1717,7 +1717,7 @@
"description": "Rimuove i metadati dei media prima di inviare come messaggio"
},
"bypass_message_retention_policy": {
"name": "Bypass Message Retention Policy",
"name": "Bypass policy di conservazione messaggi",
"description": "Previene che i messaggi vengano cancellati dopo averli visualizzati"
},
"bypass_message_action_restrictions": {
@@ -1761,7 +1761,7 @@
"description": "Utilizzare AI per generare auto-risposte intelligenti invece di messaggi di modello"
},
"ai_provider": {
"name": "AI Provider",
"name": "Provider IA",
"description": "Selezionare quale servizio AI da utilizzare per generare risposte"
},
"ai_endpoint_url": {
@@ -1773,7 +1773,7 @@
"description": "Modello AI da usare per generare risposte (ad esempio, gpt-3.5-turbo, gpt-4)"
},
"ai_api_key": {
"name": "AI API Key",
"name": "Chiave API IA",
"description": "Chiave API per l'autenticazione con il servizio AI"
},
"ai_system_prompt": {
@@ -1781,7 +1781,7 @@
"description": "Il prompt del sistema che definisce la personalità e il comportamento dell'AI"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "Token massimi IA",
"description": "Numero massimo di gettoni (parole) che l'IA può usare in risposte"
},
"ai_temperature": {
@@ -1895,11 +1895,11 @@
"description": "Consente a Auto Open Snaps di eseguire in background. Nota: Questo scolicherà significativamente la batteria"
},
"min_delay": {
"name": "Min Delay (ms)",
"name": "Ritardo minimo (ms)",
"description": "Ritardo minimo in millisecondi prima di aprire uno scatto"
},
"max_delay_ms": {
"name": "Max Delay (ms)",
"name": "Ritardo massimo (ms)",
"description": "Ritardo massimo in millisecondi prima di aprire uno scatto"
},
"queue_size": {
@@ -2124,7 +2124,7 @@
"description": "Mostra il primo nome utente creato accanto al nome utente corrente nella pagina del profilo"
},
"bypass_camera_roll_limit": {
"name": "Bypass Camera Roll Limit",
"name": "Bypass limite rullino",
"description": "Aumenta la quantità massima di supporti che puoi inviare dal rullo della fotocamera"
},
"custom_self_destruct_snap_delay": {
@@ -2136,7 +2136,7 @@
"description": "Consente di eseguire il codice JavaScript in Composer (solo Arm64)"
},
"composer_logs": {
"name": "Composer Logs",
"name": "Log di Composer",
"description": "Reindirizza i registri delle console di Composer a PurrfectSnap"
}
}
@@ -2161,7 +2161,7 @@
"properties": {
"play_store_installer_package_name": {
"name": "Nome del pacchetto dell'installatore di Play Store",
"description": "Overrides the installer package name to com.android.vending"
"description": "Sovrascrive il nome del pacchetto di installazione a com.android.vending"
},
"remove_vpn_transport_flag": {
"name": "Rimuovere VPN Bandiera di trasporto",
@@ -2256,7 +2256,7 @@
"description": "Consente funzioni Snapchat non avviate/beta"
},
"context_menu_fix": {
"name": "Context Menu Fix",
"name": "Correzione menu contestuale",
"description": "Tentare di riparare il menu di alimentazione Friend come quando il dispositivo è offline non può essere visualizzato correttamente"
},
"app_lock": {
@@ -2270,8 +2270,8 @@
}
},
"infinite_story_boost": {
"name": "Infinite Story Boost",
"description": "Bypass the Story Boost Limit delay"
"name": "Potenziamento story infinito",
"description": "Bypass ritardo limite Story Boost"
},
"meo_passcode_bypass": {
"name": "Solo i miei occhi passano il codice",
@@ -2322,7 +2322,7 @@
}
},
"scripting": {
"name": "Scripting",
"name": "Script",
"description": "Eseguire script personalizzati per estendere PurrfectSnap",
"properties": {
"developer_mode": {
@@ -2352,7 +2352,7 @@
}
},
"friend_tracker": {
"name": "Friend Tracker",
"name": "Tracker amici",
"description": "Registra l'attività dell'amico su Snapchat",
"properties": {
"record_messaging_events": {
@@ -2364,7 +2364,7 @@
"description": "Consente al tracker di eseguire in background. Nota: Questo scolicherà significativamente la batteria"
},
"auto_purge": {
"name": "Auto Purge",
"name": "Pulizia automatica",
"description": "Elimina automaticamente gli eventi cache che sono più vecchi della quantità di tempo specificata"
}
}
@@ -2390,7 +2390,7 @@
"STICKER": "Adesivi Sticker visualizzare",
"SHARE": "Condividi",
"STATUS": "Stato",
"LOCATION": "Location",
"LOCATION": "Posizione",
"STATUS_SAVE_TO_CAMERA_ROLL": "Salvato a rullo della fotocamera",
"STATUS_CONVERSATION_CAPTURE_SCREENSHOT": "Immagine dello schermo",
"STATUS_CONVERSATION_CAPTURE_RECORD": "Registrazione schermo",
@@ -2419,7 +2419,7 @@
"media_download_source": {
"none": "Nessuno",
"pending": "Finanziamenti",
"chat_media": "Chat Media",
"chat_media": "Media chat",
"story": "Storia",
"public_story": "Storia pubblica",
"spotlight": "Faretto",
@@ -2553,7 +2553,7 @@
"deleted": "Cancellato",
"business_accounts": "Conti aziendali",
"streaks": "Righe",
"non_streaks": "Non Streaks",
"non_streaks": "Senza sequenze",
"followed": "Seguito",
"following": "Seguito",
"location_on_map": "Posizione sulla mappa"
@@ -2619,15 +2619,15 @@
"message_reaction_add": "Reazione del messaggio Aggiungi",
"message_reaction_remove": "Rimozione dei messaggi",
"snap_opened": "Aperta di scatto",
"snap_replayed": "Snap Replayed",
"snap_replayed_twice": "Snap Replayed Twice",
"snap_screenshot": "Snap Screenshot",
"snap_replayed": "Snap riprodotto",
"snap_replayed_twice": "Snap riprodotto due volte",
"snap_screenshot": "Screenshot di Snap",
"snap_screen_record": "Registrazione dello schermo di scatto",
"i_can_see_you": "Posso vederti"
},
"cleared_from_feed": "Cancellato da alimentazione",
"tracker_actions": {
"log": "Log",
"log": "Registra",
"in_app_notification": "Notifica dell'applicazione",
"push_notification": "Spingere la notifica",
"custom": "Personale"
@@ -2720,8 +2720,8 @@
"incoming_secret_message": "Il tuo amico ha appena accettato la chiave pubblica. Clicca qui sotto per accettare il segreto."
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"priority_title": "Auto Open Snaps (Priority)",
"title": "Apri Snaps automaticamente",
"priority_title": "Apri Snaps automaticamente (priorit?)",
"error_title": "Auto Open Snaps (Errori)",
"channel_description": "Notifiche per l'apertura automatica dello stato della coda di scatto",
"priority_channel_description": "Notifiche ad alta priorità per l'apertura automatica",
@@ -2736,15 +2736,15 @@
"action_reset": "Conteggio di ripristino",
"error_content": "Non ha aperto lo snap da{sender}:{error}",
"resumed_feedback": "Auto aperta ripresa",
"paused_feedback": "Auto Open Paused",
"paused_feedback": "Apertura automatica in pausa",
"resumed_message": "L'elaborazione continuerà automaticamente per gli snap in coda",
"paused_message": "L'elaborazione è interrotta. Coda conservata ({count})",
"status_paused": "Paused",
"status_paused": "In pausa",
"status_monitoring": "Monitoraggio",
"status_active": "Attivo",
"queue_cleared": "Azzeramento delle statistiche",
"queue_cleared_title": "Queue schiarito",
"queue_cleared_reset": "Queue Cleared & Reset",
"queue_cleared_reset": "Coda cancellata e reimpostata",
"queue_cleared_feedback": "Cancellato{count}snap in coda • Ripristino{processed}conteggio elaborato",
"queue_cleared_feedback_simple": "Ripristino{processed}conteggio elaborato",
"unknown_sender": "Sconosciuto",
@@ -2805,7 +2805,7 @@
"delete_rule_description": "Sei sicuro di voler eliminare questa regola?",
"rule_name": "Nome della regola",
"friend_tracker_notifications": {
"notification_channel_name": "Friend Tracker",
"notification_channel_name": "Tracker amici",
"notification_title": "Attività di amico",
"conversation_enter": "{friend}iscritto{conversation}",
"conversation_exit": "{friend}sinistra{conversation}",
@@ -2830,7 +2830,7 @@
"i_can_see_you": "{friend}attività{conversation}:{details}"
},
"friend_mutation_observer": {
"notification_channel_name": "Friend Mutation Observer",
"notification_channel_name": "Osservatore mutazioni amici",
"friend_removed": "{username}ti ha rimosso come amico",
"birthday_removed": "{username}ha rimosso il loro compleanno ({birthday})",
"birthday_added": "{username}ha aggiunto il loro compleanno ({birthday})",
@@ -2845,7 +2845,7 @@
"date_range_picker_end_headline": "A",
"date_range_picker_title": "Seleziona l'intervallo di date",
"date_picker_switch_to_calendar_mode": "Calendario",
"date_picker_switch_to_input_mode": "Input",
"date_picker_switch_to_input_mode": "Inserimento",
"date_range_picker_scroll_to_previous_month": "Precedente mese",
"date_range_picker_scroll_to_next_month": "Il mese prossimo",
"date_picker_today_description": "Oggi",
@@ -2921,7 +2921,7 @@
},
"tracker": {
"tabs": {
"logs": "Logs",
"logs": "Registri",
"rules": "Regole"
},
"actions": {
@@ -3018,7 +3018,7 @@
"message_reaction_add": "aggiunto una reazione",
"message_reaction_remove": "rimosso una reazione",
"snap_opened": "aperto uno scatto",
"snap_replayed": "replayed a snap",
"snap_replayed": "ha riprodotto uno snap",
"snap_replayed_twice": "replayed a snap due volte",
"snap_screenshot": "ha preso uno screenshot",
"snap_screen_record": "schermo registrato",
@@ -3032,7 +3032,7 @@
"events": "Eventi",
"add_event": "Aggiungi evento",
"type": "Tipo",
"triggers": "Triggers",
"triggers": "Trigger",
"conditions": "Condizioni",
"only_inside_conversation": "Solo quando sono dentro la conversazione",
"only_outside_conversation": "Solo quando sono fuori a parlare",
@@ -3051,7 +3051,7 @@
"clear": "Libero",
"files": {
"config_json": "File di configurazione",
"mappings_json": "Mappings File",
"mappings_json": "File di mappatura",
"message_logger_db": "Database del Registratore di messaggi",
"pinned_best_friend_txt": "File migliore amico Pinned",
"native_sig_cache_txt": "Firma nativa File Cache"
@@ -3170,7 +3170,7 @@
},
"debug_dialogs": {
"info": "Info",
"refs": "Refs",
"refs": "Riferimenti",
"arroyo": "Arroyo",
"message": "Messaggio",
"media_references": "Riferimenti",
@@ -3185,8 +3185,8 @@
"casual": "Casuale",
"formal": "Forma",
"friendly": "Amichevole",
"humorous": "Humorous",
"empathetic": "Empathetic",
"humorous": "Spiritoso",
"empathetic": "Empatico",
"busy": "Occupato",
"toxic": "Tossico"
},
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Izvēlieties valodu",
@@ -663,16 +663,16 @@
"auto_save": "י Automātiski saglabāt vēstules",
"unsaveable_messages": " Nesaglabājamas vēstules",
"auto_open_snaps": "Snaps automātiska atvēršana",
"stealth": "👻 Stealth Mode",
"stealth": "?? Slepenais re??ms",
"auto_reply": "י Automātiska atbilde",
"auto_delete_sent_messages": "- Automātiski izdzēst nosūtītās vēstules",
"mark_snaps_as_seen": "י Atzīmēt momentus, kā redzams",
"mark_stories_as_seen_locally": "י Atzīmēt stāstus, kā redzams uz vietas",
"conversation_info": "👤 Conversation Info",
"conversation_info": "?? Sarunas inform?cija",
"e2e_encryption": "E2E šifrēšana",
"message_logger": "📝 Message Logger",
"message_logger": "?? Zi?ojumu ?urn?ls",
"auto_read": "י Automātiski lasīt",
"hide_typing_indicator": "🙈 Hide Typing Indicator"
"hide_typing_indicator": "?? Pasl?pt rakst??anas indikatoru"
},
"schedule_scheduled_for": "Plānots{name}skaits{time}",
"schedule_sending_in": "Sūta{time}",
@@ -1021,7 +1021,7 @@
"friendly": "Draudzīgi",
"humorous": "Mitrs",
"empathetic": "Empātiski",
"toxic": "Edgy",
"toxic": "Asu",
"busy": "Aizņemts"
},
"ai_temperature": {
@@ -1050,7 +1050,7 @@
"fi": "Somu"
},
"friendGreeting": {
"Hey": "Hey"
"Hey": "Sveiks"
},
"half_swipe_messages": {
"[\"I noticed you half-swiped! I'll respond soon.\"]": "Es pamanīju, ka tu esi puspeldējis! Es drīz atbildēšu."
@@ -1477,7 +1477,7 @@
"description": "Pievieno ziņojumiem specifisku rādītāju ikonas\nPiezīme: Rādītāji var nebūt 100% precīzi"
},
"stealth_mode_indicator": {
"name": "Stealth Mode indikators",
"name": "Slepen? re??ma indikators",
"description": "Papildina emoji blakus sarunām slēptā režīmā"
},
"edit_text_override": {
@@ -1536,7 +1536,7 @@
},
"mark_snap_as_seen_button": {
"name": "Atzīmēt kā meklēto pogu",
"description": "Pievieno pogu, lai atzīmētu Snap kā redzams, to aplūkojot.\nTas darbosies pat tad, ja Stealth Mode ir ieslēgts"
"description": "Pievieno pogu, lai atz?m?tu Snap k? redz?tu, to apl?kojot.\nTas darbosies pat tad, ja slepenais re??ms ir iesl?gts"
},
"skip_when_marking_as_seen": {
"name": "Izlaist, kad iezīmēt kā redzētu",
@@ -2156,7 +2156,7 @@
}
},
"spoof": {
"name": "Spoof",
"name": "Vilto?ana",
"description": "Spoof dažādu informāciju par jums",
"properties": {
"play_store_installer_package_name": {
@@ -2176,11 +2176,11 @@
"description": "Piespiest tīkla transportu ziņot Wi-Fi mobilo datu vietā"
},
"spoof_device_id": {
"name": "Spoof Device ID",
"name": "Viltot ier?ces ID",
"description": "Aizstāt uz Snapchat nosūtīto Android ID",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Viltot Android ID",
"description": "Aizstāt Android ID nosūtīts uz Snapchat ar pielāgotu vērtību"
},
"custom_android_id": {
@@ -2553,7 +2553,7 @@
"deleted": "Dzēsts",
"business_accounts": "Uzņēmējdarbības konti",
"streaks": "Streaks",
"non_streaks": "Non Streaks",
"non_streaks": "Bez streakiem",
"followed": "Seko",
"following": "Pēc",
"location_on_map": "Vieta uz kartes"
@@ -2759,7 +2759,7 @@
"notification_statistics": "STATISTIKA",
"notification_queue_size": "Rindas izmērs",
"notification_total_opened": "Atvērti visi momenti",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "RINDAS PRIEK?SKATS",
"notification_processing_continue": "Apstrāde tiks turpināta automātiski...",
"notification_no_snaps_queue": "Snaps rindā.",
"notification_queue_cleared_opened": "Rinda noskaidrota ({opened}atvērts)",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Velg språk",
@@ -58,7 +58,7 @@
"sections": {
"home": {
"version_title": "v{versionName}Av evig",
"update_title": "PurrfectSnap Update",
"update_title": "Oppdatering av PurrfectSnap",
"update_content": "Utgave{version}er tilgjengelig!",
"update_button": "Last ned",
"debug_build_summary_title": "Du kjører en feilsøkingsbygging av PurrfectSnap",
@@ -84,14 +84,14 @@
"clear_button": "Fjern",
"view_logger_history_button": "Vis Logger Historie",
"ui_settings_title": "UI-innstillinger",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptisk tilbakemelding",
"use_system_toasts_label": "Bruk System Toasts",
"updates_title": "Oppdateringer",
"auto_update_check": "Automatisk oppdateringskontroll",
"update_check_frequency_daily": "Daglig",
"update_check_frequency_weekly": "Uken",
"update_check_frequency_monthly": "Månedlig",
"update_channel_stable": "Stable",
"update_channel_stable": "Stabil",
"update_channel_prerelease": "Forutsetning",
"update_notification_channel_name": "Oppdateringer",
"update_notification_channel_description": "Bli varslet når nye utgivelser er tilgjengelige",
@@ -240,7 +240,7 @@
"save_coordinates_dialog_title": "Lagre koordinater",
"saved_name_dialog_hint": "Lagret navn",
"latitude_dialog_hint": "Lengdegrad",
"longitude_dialog_hint": "Longitude",
"longitude_dialog_hint": "Lengdegrad",
"save_dialog_button": "Lagre",
"choose_location_button": "Velg et sted",
"manual_coordinates_hint": "Finjuster koordinatene manuelt.",
@@ -305,7 +305,7 @@
"clear_module_data_failed": "Klarte ikke å slette moduldata",
"delete_module_button": "Slett",
"delete_module_failed": "Klarte ikke å slette modulen",
"documentation_button": "Docs",
"documentation_button": "Dokumentasjon",
"download_script_failed": "Klarte ikke å laste ned skript",
"downloading_script": "Last ned skript...",
"edit_module_button": "Rediger",
@@ -389,12 +389,12 @@
"scope_all": "Alle venner/grupper",
"scope_whitelist": "Ingen andre enn",
"scope_blacklist": "Alle bortsett fra",
"events_section_title": "Events",
"events_section_title": "Hendelser",
"events_suffix": "hendelser",
"no_events_text": "Ingen hendelser lagt til enda",
"add_event_dialog_title": "Legg til hendelse",
"event_type_label": "Event Type",
"triggers_title": "Triggers",
"event_type_label": "Hendelsestype",
"triggers_title": "Utl?sere",
"conditions_title": "Betingelser",
"condition_only_inside_conversation": "Først når jeg er inne i samtalen",
"condition_only_outside_conversation": "Først når jeg er utenfor samtale",
@@ -546,7 +546,7 @@
"name": "Bruk E2E-kryptering"
},
"pin_conversation": {
"name": "Pin Conversation"
"name": "Fest samtale"
},
"exclude_message_logger": {
"name": "Utelat fra meldingslogger"
@@ -595,7 +595,7 @@
},
"actions": {
"clean_snapchat_cache": {
"name": "Clean Snapchat Cache",
"name": "Rens Snapchat-buffer",
"description": "Rengjør Snapchat Cache"
},
"manage_friend_list": {
@@ -645,7 +645,7 @@
"app_appearance": {
"always_light": "Alltid lys",
"always_dark": "Alltid mørk",
"null": "Match System"
"null": "F?lg system"
},
"auto_reload": {
"snapchat_only": "Last bare Snapchat på nytt",
@@ -662,7 +662,7 @@
"auto_download": "⬇️ Automatisk nedlasting",
"auto_save": "💬 Lagre meldinger automatisk",
"unsaveable_messages": "⬇️ Ubesparelige meldinger",
"auto_open_snaps": "📷 Auto Open Snaps",
"auto_open_snaps": "?? ?pne Snaps automatisk",
"stealth": "👻 Stealth-modus",
"auto_reply": "📨 Automatisk svar",
"auto_delete_sent_messages": "🗑️ Auto Slett Sendte meldinger",
@@ -716,7 +716,7 @@
"notifications": {
"chat_screenshot": "Skjermbilde",
"chat_screen_record": "Skjermopptak",
"snap_replay": "Snap Replay",
"snap_replay": "Snap-avspilling",
"camera_roll_save": "Kamerarulle Lagre",
"chat": "Chat",
"chat_reply": "Chat Svar",
@@ -733,43 +733,43 @@
"map_live_location": "Kart Live Beliggenhet"
},
"auto_read": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"hide_typing_indicator": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"auto_delete_sent_messages": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"auto_download": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"message_logger": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"auto_reply": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"blacklist": "Svarteliste",
"whitelist": "Hviteliste",
"disabled": "Deaktivert"
},
"custom_android_id": {
@@ -888,17 +888,17 @@
"null": "Automatisk"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatisk"
},
"snapchat_plus": {
"not_subscribed": "Ikke Abonnert",
"basic": "Grunnleggende",
"ad_free": "Ad Free",
"ad_free": "Reklamefri",
"null": "Standard"
},
"bypass_video_length_restriction": {
"single": "Enkeltmedier",
"split": "Split media",
"split": "Del opp media",
"null": "Standard"
},
"old_bitmoji_selfie": {
@@ -990,7 +990,7 @@
"message_types": {
"CHAT": "Chat",
"SNAP": "Snap",
"NOTE": "Note",
"NOTE": "Notat",
"EXTERNAL_MEDIA": "Eksterne medier",
"STICKER": "Klistremerke"
},
@@ -1016,19 +1016,19 @@
"friendly, casual, helpful, empathetic": "vennlig, casual, hjelpsom, empatisk"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Uformell",
"formal": "Formell",
"friendly": "Vennlig",
"humorous": "Humorøs",
"empathetic": "Empatisk",
"toxic": "Edgy",
"toxic": "Kantete",
"busy": "Opptatt"
},
"ai_temperature": {
"0.7": "Balansert (0,7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatisk",
"en": "Norsk",
"es": "Spansk",
"fr": "Fransk",
@@ -1085,12 +1085,12 @@
"auto_reply_content_types": {
"chat_messages": "Chatmeldinger",
"snap_messages": "Snaps",
"story_share_messages": "Story Shares",
"story_share_messages": "Story-deling",
"story_reply_messages": "Story Svar",
"external_media_messages": "Eksterne medier",
"voice_note_messages": "Stemmenotater",
"sticker_messages": "Klistremerker",
"tiny_snap_messages": "Tiny Snaps",
"tiny_snap_messages": "Mini-snaps",
"map_reaction_messages": "Kart Reaksjoner",
"half_swipes": "Halvstrekk"
},
@@ -1139,7 +1139,7 @@
"description": "Angi koordinatene for den spekulerte plasseringen"
},
"walk_radius": {
"name": "Walk Radius",
"name": "G?-radius",
"description": "Tilfeldig gå rundt i denne radiusen (ft)"
},
"always_update_location": {
@@ -1181,7 +1181,7 @@
"description": "Slår av bildekomprimering når du laster opp medier"
},
"custom_image_upload_format": {
"name": "Custom Image Upload Format",
"name": "Tilpasset bildeopplastingsformat",
"description": "Angi et egendefinert bildeopplastingsformat\nVelg et tapsfritt format (som PNG) for den beste kvaliteten"
}
}
@@ -1191,7 +1191,7 @@
"description": "Bekreft automatisk valgte handlinger"
},
"auto_updater": {
"name": "Auto Updater",
"name": "Auto-oppdaterer",
"description": "Kontroller automatisk for nye oppdateringer"
},
"update_settings": {
@@ -1210,7 +1210,7 @@
"name": "UI-innstillinger",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Haptisk tilbakemelding"
}
}
},
@@ -1281,7 +1281,7 @@
}
},
"downloader": {
"name": "Downloader",
"name": "Nedlaster",
"description": "Last ned Snapchat Media",
"properties": {
"save_folder": {
@@ -1543,7 +1543,7 @@
"description": "Hopper automatisk til neste Snap når det markeres en Snap som sett.\nBruk i kombinasjon med Mark Snap som sett knapp"
},
"loop_media_playback": {
"name": "Loop Media Playback",
"name": "Loop av medieavspilling",
"description": "Loops media avspilling når du ser Snaps / Historier"
},
"disable_replay_in_ff": {
@@ -1647,7 +1647,7 @@
"description": "Forhindrer at dine egne meldinger slettes"
},
"auto_purge": {
"name": "Auto Purge",
"name": "Automatisk rensing",
"description": "Sletter automatisk cachede meldinger som er eldre enn angitt tid"
},
"message_filter": {
@@ -1713,7 +1713,7 @@
}
},
"strip_media_metadata": {
"name": "Strip Media Metadata",
"name": "Fjern mediametadata",
"description": "Fjerner metadata av medier før du sender som melding"
},
"bypass_message_retention_policy": {
@@ -1729,7 +1729,7 @@
"description": "Tillater deg å vise gruppeinformasjon etter å ha blitt sparket"
},
"double_tap_chat_action": {
"name": "Double Tap Chat Action",
"name": "Dobbelttrykk-chat-handling",
"description": "Utfører en egendefinert handling når dobbelt trykk på en melding i chat"
},
"double_tap_chat_action_custom_emoji": {
@@ -1781,7 +1781,7 @@
"description": "Systemprompt som definerer AIs personlighet og oppførsel"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "AI maks tokens",
"description": "Maksimalt antall tokens (ord) AI kan bruke i svar"
},
"ai_temperature": {
@@ -1793,11 +1793,11 @@
"description": "Antall tidligere meldinger som skal inkluderes som kontekst for AI-svar"
},
"ai_personality_traits": {
"name": "AI Personality Traits",
"name": "AI-personlighetstrekk",
"description": "Kommaseparerte personlighetstrekk for AI (f.eks. vennlig, uformell, hjelpsom)"
},
"ai_response_style": {
"name": "AI Response Style",
"name": "AI-svarstil",
"description": "Samlet stil for AI svar"
},
"ai_response_language": {
@@ -1839,7 +1839,7 @@
"description": "Hellig tekst å bruke når venn spesifikk hilsen er aktivert"
},
"auto_reply_content_types": {
"name": "Auto Reply Triggers",
"name": "Auto-svar-utl?sere",
"description": "Velg hvilke meldingstyper som skal utløse automatisk"
},
"chat_messages": {
@@ -1991,7 +1991,7 @@
"description": "Paus oversettelse når tjenesten er blokkert"
},
"max_retries": {
"name": "Max Retries",
"name": "Maks antall fors?k",
"description": "Maksimalt antall forsøk"
},
"retry_delay": {
@@ -2068,7 +2068,7 @@
"description": "Angi en egendefinert kameraoppløsning, bredde x høyde (f.eks. 1920x1080).\nTilpasset oppløsning må støttes av enheten"
},
"front_custom_frame_rate": {
"name": "Front Custom Frame Rate",
"name": "Tilpasset bildefrekvens foran",
"description": "Overstyrer frontkamerarammehastigheten"
},
"back_custom_frame_rate": {
@@ -2112,7 +2112,7 @@
"description": "Eksperimentelle funksjoner",
"properties": {
"native_hooks": {
"name": "Native Hooks",
"name": "Native hooks",
"description": "Usikre funksjoner som kroker inn i Snapchats opprinnelige kode",
"properties": {
"composer_hooks": {
@@ -2180,7 +2180,7 @@
"description": "Overstyr Android-ID-en sendt til Snapchat",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Spoof Android-ID",
"description": "Overstyr Android-ID-en som sendes til Snapchat med en egendefinert verdi"
},
"custom_android_id": {
@@ -2204,15 +2204,15 @@
"description": "Konverterer snaps til chat eksterne medier lokalt. Dette vises i chat nedlastings-menyen"
},
"media_file_picker": {
"name": "Media File Picker",
"name": "Mediefilvelger",
"description": "La deg velge en video/audio-fil fra galleriet"
},
"story_logger": {
"name": "Story Logger",
"name": "Story-logg",
"description": "Gir en historie av venner historier"
},
"call_recorder": {
"name": "Call Recorder",
"name": "Samtalsopptaker",
"description": "Opptak av lydsamtaler automatisk"
},
"account_switcher": {
@@ -2252,7 +2252,7 @@
"description": "Lar deg legge til notater til venner profiler"
},
"cof_experiments": {
"name": "COF Experiments",
"name": "COF-eksperimenter",
"description": "Aktiverer uutgitte/beta Snapchat-funksjoner"
},
"context_menu_fix": {
@@ -2260,7 +2260,7 @@
"description": "Prøv å reparere Venn Feed-menyen som når enheten er frakoblet kan den ikke vises riktig"
},
"app_lock": {
"name": "App Lock",
"name": "App-l?s",
"description": "Forhindrer tilgang til Snapchat uten kode",
"properties": {
"lock_on_resume": {
@@ -2270,7 +2270,7 @@
}
},
"infinite_story_boost": {
"name": "Infinite Story Boost",
"name": "Uendelig Story-boost",
"description": "Overgå historien Boost Limit forsinkelse"
},
"meo_passcode_bypass": {
@@ -2322,7 +2322,7 @@
}
},
"scripting": {
"name": "Scripting",
"name": "Skripting",
"description": "Kjør egendefinerte skript for å utvide PurrfectSnap",
"properties": {
"developer_mode": {
@@ -2364,7 +2364,7 @@
"description": "La sporeren kjøre i bakgrunnen. Merk: Dette vil betydelig drenere batteriet ditt"
},
"auto_purge": {
"name": "Auto Purge",
"name": "Automatisk rensing",
"description": "Sletter automatisk cachede hendelser som er eldre enn den angitte tiden"
}
}
@@ -2402,29 +2402,29 @@
"FAMILY_CENTER_ACCEPT": "Familiesenter aksepterer",
"FAMILY_CENTER_LEAVE": "Familiesenter",
"STATUS_PLUS_GIFT": "Status Plus-gave",
"TINY_SNAP": "Tiny Snap",
"TINY_SNAP": "Mini-snap",
"STATUS_COUNTDOWN": "Nedtelling",
"MAP_REACTION": "Kart Reaksjon",
"chat_messages": "Chatmeldinger",
"snap_messages": "Snaps",
"story_share_messages": "Story Shares",
"story_share_messages": "Story-deling",
"story_reply_messages": "Story Svar",
"external_media_messages": "Eksterne medier",
"voice_note_messages": "Stemmenote",
"sticker_messages": "Klistremerke",
"tiny_snap_messages": "Tiny Snap",
"tiny_snap_messages": "Mini-snap",
"map_reaction_messages": "Kart Reaksjon",
"half_swipes": "Halvstrekk"
},
"media_download_source": {
"none": "Ingen",
"pending": "Venter",
"chat_media": "Chat Media",
"chat_media": "Chat-medier",
"story": "Story",
"public_story": "Offentlig historie",
"spotlight": "Spotlight",
"profile_picture": "Profilbilde",
"story_logger": "Story Logger",
"story_logger": "Story-logg",
"message_logger": "Meldingslogger",
"merged": "Flettet",
"voice_call": "Stemmesamtale"
@@ -2553,7 +2553,7 @@
"deleted": "Slettet",
"business_accounts": "Forretningskontoer",
"streaks": "Streaks",
"non_streaks": "Non Streaks",
"non_streaks": "Uten streaks",
"followed": "Følgt",
"following": "Følger",
"location_on_map": "Beliggenhet på kart"
@@ -2562,7 +2562,7 @@
"none": "Ingen",
"username": "Brukernavn",
"added_timestamp": "Lagt til Timestamp",
"snap_score": "Snap Score",
"snap_score": "Snap-poeng",
"streak_length": "Streak Lengde",
"most_messages_sent": "De fleste meldinger sendt",
"most_recent_message": "Siste melding",
@@ -2619,10 +2619,10 @@
"message_reaction_add": "Meldingsreaksjon Legg til",
"message_reaction_remove": "Meldingsreaksjon Fjern",
"snap_opened": "Snap åpnet",
"snap_replayed": "Snap Replayed",
"snap_replayed": "Snap spilt av igjen",
"snap_replayed_twice": "Snap Replayed to ganger",
"snap_screenshot": "Snap skjermbilde",
"snap_screen_record": "Snap Screen Record",
"snap_screen_record": "Snap-skjermopptak",
"i_can_see_you": "Jeg kan se deg"
},
"cleared_from_feed": "Fjernet fra fôr",
@@ -2660,8 +2660,8 @@
"sticker": "Klistremerke",
"gif": "GIF",
"external_media": "Eksterne medier",
"note": "Note",
"original_story": "Original Story"
"note": "Notat",
"original_story": "Original-story"
},
"select_attachments_title": "Velg vedlegg",
"download_started_toast": "Last ned startet",
@@ -2721,7 +2721,7 @@
},
"auto_open_snaps": {
"title": "Åpne snaps automatisk",
"priority_title": "Auto Open Snaps (Priority)",
"priority_title": "?pne Snaps automatisk (prioritet)",
"error_title": "Åpne snaps automatisk (feil)",
"channel_description": "Varsler for automatisk åpnende snaps kø status",
"priority_channel_description": "Høy prioritetsvarsler for auto-åpning snaps",
@@ -2759,7 +2759,7 @@
"notification_statistics": "STATISTIK",
"notification_queue_size": "Køstørrelse",
"notification_total_opened": "Total Snaps åpnet",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "K?FORH?NDSVISNING",
"notification_processing_continue": "Behandling vil fortsette automatisk...",
"notification_no_snaps_queue": "Ingen snaps i kø.",
"notification_queue_cleared_opened": "Køyeklart ({opened}åpnet)",
@@ -2948,7 +2948,7 @@
"types": {
"username": "Brukernavn",
"conversation": "Kontakt",
"event": "Event"
"event": "Hendelse"
},
"event_types": {
"conversation_enter": "Entert samtale",
@@ -2967,7 +2967,7 @@
"message_reaction_add": "Legg til reaksjon",
"message_reaction_remove": "Fjernet reaksjon",
"snap_opened": "Åpnet snap",
"snap_replayed": "Replayed snap",
"snap_replayed": "Spilte av snap igjen",
"snap_replayed_twice": "Replayed snap to ganger",
"snap_screenshot": "Tatt skjermbilde",
"snap_screen_record": "Skjermen registrert"
@@ -2993,7 +2993,7 @@
"in_conversation": "i{conversation}",
"unknown_user": "Ukjend",
"unknown_conversation": "DMs",
"i_can_see_you_entered": "Entered",
"i_can_see_you_entered": "Gikk inn",
"i_can_see_you_left": "Venstre",
"i_can_see_you_duration": "Varighet",
"i_can_see_you_not_available": "N/A",
@@ -3018,7 +3018,7 @@
"message_reaction_add": "tilsatt en reaksjon",
"message_reaction_remove": "fjernet en reaksjon",
"snap_opened": "åpnet en snap",
"snap_replayed": "replayed a snap",
"snap_replayed": "spilte av en snap igjen",
"snap_replayed_twice": "replayed a snap to ganger",
"snap_screenshot": "ta et skjermbilde",
"snap_screen_record": "skjerm innspilt",
@@ -3029,10 +3029,10 @@
"edit_rule": {
"custom_rule": "Egendefinert regel",
"scope": "Område",
"events": "Events",
"events": "Hendelser",
"add_event": "Legg til hendelse",
"type": "Type",
"triggers": "Triggers",
"triggers": "Utl?sere",
"conditions": "Betingelser",
"only_inside_conversation": "Først når jeg er inne i samtalen",
"only_outside_conversation": "Først når jeg er utenfor samtale",
@@ -3064,13 +3064,13 @@
}
},
"ui_settings_title": "UI-innstillinger",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptisk tilbakemelding",
"updates_title": "Oppdateringer",
"auto_update_check": "Automatisk oppdateringskontroll",
"update_check_frequency_daily": "Daglig",
"update_check_frequency_weekly": "Uken",
"update_check_frequency_monthly": "Månedlig",
"update_channel_stable": "Stable",
"update_channel_stable": "Stabil",
"update_channel_prerelease": "Forutsetning",
"friend_notes_title": "Vennnotater",
"friend_notes_description": "Administrere og sikkerhetskopiere dine vennenotater",
@@ -3182,7 +3182,7 @@
"failed_to_edit_message": "Klarte ikke å redigere melding:{error}"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Uformell",
"formal": "Formell",
"friendly": "Vennlig",
"humorous": "Humorøs",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Taal selecteren",
@@ -30,7 +30,7 @@
"home": "Begin",
"home_about": "Info",
"home_settings": "Instellingen",
"home_logs": "Logs",
"home_logs": "Logboeken",
"logger_history": "Loggergeschiedenis",
"logged_stories": "Gelogde verhalen",
"friend_tracker": "Vriend Tracker",
@@ -52,13 +52,13 @@
"customize_bottom_bar_subtitle": "Kies welke tabbladen getoond worden op uw startscherm",
"available_tabs_title": "Beschikbare tabbladen",
"shown_tabs_title": "Tabbladen tonen",
"reset_button": "Reset",
"reset_button": "Resetten",
"done_button": "Klaar"
},
"sections": {
"home": {
"version_title": "v{versionName}· door Eternal",
"update_title": "PurrfectSnap Update",
"update_title": "PurrfectSnap-update",
"update_content": "Versie{version}is beschikbaar!",
"update_button": "Downloaden",
"debug_build_summary_title": "U draait een debug bouw van PurrfectSnap",
@@ -92,7 +92,7 @@
"update_check_frequency_weekly": "Wekelijks",
"update_check_frequency_monthly": "Maandelijks",
"update_channel_stable": "Stabiel",
"update_channel_prerelease": "Pre-release",
"update_channel_prerelease": "Vooruitgave",
"update_notification_channel_name": "Bijwerken",
"update_notification_channel_description": "Waarschuwen wanneer nieuwe releases beschikbaar zijn",
"update_notification_title": "Nieuwe update beschikbaar",
@@ -113,7 +113,7 @@
"customize_bottom_bar_title": "Onderbalk aanpassen",
"customize_bottom_bar_subtitle": "Kies welke tabbladen getoond worden op uw startscherm",
"available_tabs_title": "Beschikbare tabbladen",
"reset_button": "Reset",
"reset_button": "Resetten",
"done_button": "Klaar",
"clear_friend_feed": "Vriend Feed wissen",
"test_mode_label": "PurrAura inschakelen",
@@ -136,7 +136,7 @@
"disabled": "Uitgeschakeld",
"export_option": "Uitvoer",
"import_option": "Importeren",
"reset_option": "Reset",
"reset_option": "Resetten",
"config_export_success_toast": "Configureren met succes",
"config_import_success_toast": "Configureren met succes",
"config_import_failure_toast": "Kon config niet importeren{error}",
@@ -360,7 +360,7 @@
},
"friend_tracker": {
"rules_tab": "Regels",
"logs_tab": "Logs",
"logs_tab": "Logboeken",
"catalog_button": "Catalogus",
"add_rule_button": "Regel toevoegen",
"import_button": "Importeren",
@@ -711,7 +711,7 @@
"started": "Gestart",
"success": "Succes",
"progress": "Voortgang",
"failure": "Failure"
"failure": "Mislukking"
},
"notifications": {
"chat_screenshot": "Schermafdruk",
@@ -734,42 +734,42 @@
},
"auto_read": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"hide_typing_indicator": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"auto_delete_sent_messages": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"auto_download": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"stealth": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"auto_save": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"message_logger": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"auto_reply": {
"blacklist": "Zwarte lijst",
"whitelist": "Whitelist",
"whitelist": "Witte lijst",
"disabled": "Uitgeschakeld"
},
"custom_android_id": {
@@ -888,7 +888,7 @@
"null": "Automatisch"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatisch"
},
"snapchat_plus": {
"not_subscribed": "Niet ingeschreven",
@@ -1016,7 +1016,7 @@
"friendly, casual, helpful, empathetic": "vriendelijk, casual, behulpzaam, empathisch"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Informeel",
"formal": "Formele",
"friendly": "Vriendelijk",
"humorous": "Humoristisch",
@@ -1028,7 +1028,7 @@
"0.7": "Gebalanceerd (0,7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatisch",
"en": "Engels",
"es": "Spaans",
"fr": "Frans",
@@ -1050,7 +1050,7 @@
"fi": "Fins"
},
"friendGreeting": {
"Hey": "Hey"
"Hey": "Hoi"
},
"half_swipe_messages": {
"[\"I noticed you half-swiped! I'll respond soon.\"]": "Ik zag dat je halfgeknipt was. Ik zal snel reageren."
@@ -1445,7 +1445,7 @@
"description": "Selecteer welke UI-componenten te verbergen zijn"
},
"opera_media_quick_info": {
"name": "Opera Media Quick Info",
"name": "Opera Media snelinfo",
"description": "Toont nuttige informatie van media zoals aanmaakdatum in het contextmenu van de operaviewer"
},
"old_bitmoji_selfie": {
@@ -1713,7 +1713,7 @@
}
},
"strip_media_metadata": {
"name": "Strip Media Metadata",
"name": "Metadata van media verwijderen",
"description": "Verwijdert metadata van media voordat u als bericht verstuurt"
},
"bypass_message_retention_policy": {
@@ -1761,7 +1761,7 @@
"description": "Gebruik AI om intelligente automatische antwoorden te genereren in plaats van sjabloonberichten"
},
"ai_provider": {
"name": "AI Provider",
"name": "AI-provider",
"description": "Selecteer welke AI-service moet worden gebruikt voor het genereren van antwoorden"
},
"ai_endpoint_url": {
@@ -1781,7 +1781,7 @@
"description": "Systeemprompt die de persoonlijkheid en het gedrag van de AI definieert"
},
"ai_max_tokens": {
"name": "AI Max Tokens",
"name": "AI max tokens",
"description": "Maximum aantal tokens (woorden) dat de AI kan gebruiken in antwoorden"
},
"ai_temperature": {
@@ -1991,7 +1991,7 @@
"description": "Vertaling pauzeren bij geblokkeerde dienst"
},
"max_retries": {
"name": "Max Retries",
"name": "Max. herhalingen",
"description": "Maximum aantal herhalingspogingen"
},
"retry_delay": {
@@ -2112,7 +2112,7 @@
"description": "Experimentele kenmerken",
"properties": {
"native_hooks": {
"name": "Native Hooks",
"name": "Native hooks",
"description": "Onveilige functies die Snapchat's native code haaken",
"properties": {
"composer_hooks": {
@@ -2124,7 +2124,7 @@
"description": "Toont de eerste aangemaakte gebruikersnaam naast de huidige gebruikersnaam in de profielpagina"
},
"bypass_camera_roll_limit": {
"name": "Bypass Camera Roll Limit",
"name": "Camerarol-limiet omzeilen",
"description": "Verhoogt de maximale hoeveelheid media die u vanaf de camerarol kunt verzenden"
},
"custom_self_destruct_snap_delay": {
@@ -2180,7 +2180,7 @@
"description": "Android-ID naar Snapchat negeren",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Android-ID spoofen",
"description": "De Android-ID die naar Snapchat wordt verzonden met een aangepaste waarde negeren"
},
"custom_android_id": {
@@ -2208,7 +2208,7 @@
"description": "Hiermee kunt u een video/audiobestand uit de galerij te kiezen"
},
"story_logger": {
"name": "Story Logger",
"name": "Story-logger",
"description": "Biedt een geschiedenis van vrienden verhalen"
},
"call_recorder": {
@@ -2238,7 +2238,7 @@
"description": "De voorkeurstaal voor het voicenote transcript (bv. EN, ES, FR)"
},
"notification_transcript": {
"name": "Notification Transcript",
"name": "Notificatietranscript",
"description": "Voice notes overschrijven in meldingen\nDeze functie vereist dat de Chat Preview functie wordt ingeschakeld in betere meldingen"
}
}
@@ -2322,7 +2322,7 @@
}
},
"scripting": {
"name": "Scripting",
"name": "Scripten",
"description": "Aangepaste scripts uitvoeren om PurrfectSnap te verlengen",
"properties": {
"developer_mode": {
@@ -2401,7 +2401,7 @@
"FAMILY_CENTER_INVITE": "Uitnodiging familiecentrum",
"FAMILY_CENTER_ACCEPT": "Familiecentrum accepteren",
"FAMILY_CENTER_LEAVE": "Familiecentrumverlof",
"STATUS_PLUS_GIFT": "Status Plus Gift",
"STATUS_PLUS_GIFT": "Status Plus-cadeau",
"TINY_SNAP": "Kleine snap",
"STATUS_COUNTDOWN": "Aftellen",
"MAP_REACTION": "Kaartreactie",
@@ -2410,7 +2410,7 @@
"story_share_messages": "Story-aandelen",
"story_reply_messages": "Verhaal Antwoorden",
"external_media_messages": "Externe media",
"voice_note_messages": "Voice Note",
"voice_note_messages": "Spraakbericht",
"sticker_messages": "Sticker",
"tiny_snap_messages": "Kleine snap",
"map_reaction_messages": "Kaartreactie",
@@ -2424,7 +2424,7 @@
"public_story": "Publiek verhaal",
"spotlight": "Spotlight",
"profile_picture": "Profielafbeelding",
"story_logger": "Story Logger",
"story_logger": "Story-logger",
"message_logger": "Berichtlogger",
"merged": "Samengevoegd",
"voice_call": "Stemgesprek"
@@ -2596,7 +2596,7 @@
"negative": "Nee",
"cancel": "Annuleren",
"save": "Opslaan",
"open": "Open",
"open": "Openen",
"download": "Downloaden",
"send": "Verzenden",
"restore_original": "Origineel herstellen",
@@ -2627,7 +2627,7 @@
},
"cleared_from_feed": "Ontruimd van voer",
"tracker_actions": {
"log": "Log",
"log": "Loggen",
"in_app_notification": "In-App kennisgeving",
"push_notification": "Aanmelding pushen",
"custom": "Aangepast"
@@ -2679,7 +2679,7 @@
"failed_gallery_toast": "Opslaan in galerij is mislukt{error}",
"dash_no_chapter": "Geen hoofdstuk gevonden",
"dash_dialog": {
"title": "Download dash media",
"title": "Dash-media downloaden",
"download_all": "Alles downloaden",
"segment_text": "Segment{from}-{to}"
}
@@ -2759,7 +2759,7 @@
"notification_statistics": "STATISTIEK",
"notification_queue_size": "Wachtrijgrootte",
"notification_total_opened": "Totaal aantal geopende snaps",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "WACHTRIJ-VOORBEELD",
"notification_processing_continue": "De verwerking gaat automatisch door...",
"notification_no_snaps_queue": "Geen kiekjes in de rij.",
"notification_queue_cleared_opened": "Wachtrij is leeg ({opened}geopend)",
@@ -2921,7 +2921,7 @@
},
"tracker": {
"tabs": {
"logs": "Logs",
"logs": "Logboeken",
"rules": "Regels"
},
"actions": {
@@ -3071,7 +3071,7 @@
"update_check_frequency_weekly": "Wekelijks",
"update_check_frequency_monthly": "Maandelijks",
"update_channel_stable": "Stabiel",
"update_channel_prerelease": "Pre-release",
"update_channel_prerelease": "Vooruitgave",
"friend_notes_title": "Vriendelijke opmerkingen",
"friend_notes_description": "Beheer en backup van uw vriend notities",
"app_theme_title": "App-thema",
@@ -3170,7 +3170,7 @@
},
"debug_dialogs": {
"info": "Informatie",
"refs": "Refs",
"refs": "Referenties",
"arroyo": "Arroyo",
"message": "Bericht",
"media_references": "Media Referenties",
@@ -3182,7 +3182,7 @@
"failed_to_edit_message": "Bewerken van bericht mislukt:{error}"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Informeel",
"formal": "Formele",
"friendly": "Vriendelijk",
"humorous": "Humoristisch",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Wybierz język",
@@ -52,7 +52,7 @@
"customize_bottom_bar_subtitle": "Wybierz, które karty pokazać na ekranie",
"available_tabs_title": "Dostępne karty",
"shown_tabs_title": "Pokazane karty",
"reset_button": "Reset",
"reset_button": "Resetuj",
"done_button": "Gotowe"
},
"sections": {
@@ -84,7 +84,7 @@
"clear_button": "Czysto",
"view_logger_history_button": "Widok Historia logowania",
"ui_settings_title": "Ustawienia interfejsu użytkownika",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptyczne sprz??enie zwrotne",
"use_system_toasts_label": "Użyj tostów systemowych",
"updates_title": "Aktualizacje",
"auto_update_check": "Automatyczne sprawdzanie aktualizacji",
@@ -113,9 +113,9 @@
"customize_bottom_bar_title": "Dostosuj pasek dolny",
"customize_bottom_bar_subtitle": "Wybierz, które karty pokazać na ekranie",
"available_tabs_title": "Dostępne karty",
"reset_button": "Reset",
"reset_button": "Resetuj",
"done_button": "Gotowe",
"clear_friend_feed": "Clear Friend Feed",
"clear_friend_feed": "Wyczy?? kana? znajomych",
"test_mode_label": "Włącz PurrAura",
"disable_feature_loading_label": "Wyłącz ładowanie funkcji",
"disable_auto_mapper_label": "Wyłącz automapper",
@@ -135,8 +135,8 @@
"features": {
"disabled": "Wyłączone",
"export_option": "Eksport",
"import_option": "Import",
"reset_option": "Reset",
"import_option": "Importuj",
"reset_option": "Resetuj",
"config_export_success_toast": "Konfiguracja eksportowana pomyślnie",
"config_import_success_toast": "Konfiguracja importowana pomyślnie",
"config_import_failure_toast": "Nie udało się zaimportować pliku konfiguracyjnego{error}",
@@ -310,7 +310,7 @@
"downloading_script": "Pobieranie skryptu...",
"edit_module_button": "Edycja",
"enter_url_label": "Wprowadź URL",
"import_button": "Import",
"import_button": "Importuj",
"import_from_url_button": "Importuj z URL",
"import_script_from_url_title": "Importuj skrypt z URL",
"import_script_warning": "Zainstaluj skrypty tylko ze źródeł, którym ufasz.",
@@ -363,10 +363,10 @@
"logs_tab": "Logi",
"catalog_button": "Katalog",
"add_rule_button": "Dodaj zasadę",
"import_button": "Import",
"import_button": "Importuj",
"filters_title": "Filtry",
"search_by_label": "Szukaj",
"newest_first_label": "Newest first",
"newest_first_label": "Najnowsze najpierw",
"since_label": "Od",
"until_label": "Do",
"unit_label": "Jednostka",
@@ -438,7 +438,7 @@
},
"friend_tracker_import": {
"title": "Importuj śledzenie znajomych",
"confirm_button": "Import",
"confirm_button": "Importuj",
"back_button_description": "Wracaj",
"expand_button_description": "Kategoria rozszerzania lub załamania",
"imported_toast": "Tracker importowany",
@@ -479,7 +479,7 @@
"config_import": {
"title": "Importuj podsumowanie konfiguracji",
"back_button_description": "Wracaj",
"confirm_button": "Import",
"confirm_button": "Importuj",
"expand_button_description": "Kategoria rozszerzania lub załamania",
"enabled": "Włączone",
"disabled": "Wyłączone",
@@ -615,7 +615,7 @@
"description": "Wykonuje operacje takie jak usuwanie przyjaciół lub masowe usuwanie rozmów"
},
"regen_mappings": {
"name": "Regenerate Mappings",
"name": "Regeneruj mapowania",
"description": "Mappingi regenerowane ręcznie"
},
"change_language": {
@@ -734,42 +734,42 @@
},
"auto_read": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"hide_typing_indicator": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"auto_delete_sent_messages": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"auto_download": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"stealth": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"auto_save": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"message_logger": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"auto_reply": {
"blacklist": "Czarna lista",
"whitelist": "Whitelist",
"whitelist": "Lista dozwolonych",
"disabled": "Wyłączone"
},
"custom_android_id": {
@@ -888,12 +888,12 @@
"null": "Automatyczne"
},
"update_check_frequency": {
"null": "Auto"
"null": "Automatycznie"
},
"snapchat_plus": {
"not_subscribed": "Nie subskrybowane",
"basic": "Podstawowe",
"ad_free": "Ad Free",
"ad_free": "Bez reklam",
"null": "Domyślne"
},
"bypass_video_length_restriction": {
@@ -909,7 +909,7 @@
"disable_confirmation_dialogs": {
"erase_message": "Usuń wiadomość",
"remove_friend": "Usuń przyjaciela",
"block_friend": "Block Friend",
"block_friend": "Zablokuj znajomego",
"ignore_friend": "Ignoruj Przyjaciela",
"hide_friend": "Ukryj przyjaciela",
"hide_conversation": "Ukryj rozmowę",
@@ -1021,14 +1021,14 @@
"friendly": "Przyjazny",
"humorous": "Zabawny",
"empathetic": "Empatia",
"toxic": "Edgy",
"toxic": "Ostry",
"busy": "Zajęty"
},
"ai_temperature": {
"0.7": "Zbalansowany (0,7)"
},
"ai_response_language": {
"auto": "Auto",
"auto": "Automatycznie",
"en": "Angielski",
"es": "Hiszpański",
"fr": "Francuski",
@@ -1092,7 +1092,7 @@
"sticker_messages": "Naklejki",
"tiny_snap_messages": "Małe trzaski",
"map_reaction_messages": "Reakcje mapy",
"half_swipes": "Half Swipes"
"half_swipes": "Po?owiczne przesuni?cia"
},
"supported_languages": {
"en": "Angielski",
@@ -1112,7 +1112,7 @@
"translation_position": {
"above": "Powyższy tekst",
"below": "Poniżej tekstu",
"inline": "Inline"
"inline": "W linii"
},
"source_language": {
"auto": "Wykrywanie automatycznie"
@@ -1139,7 +1139,7 @@
"description": "Ustaw współrzędne lokalizacji"
},
"walk_radius": {
"name": "Walk Radius",
"name": "Promie? chodzenia",
"description": "Losowo chodzić w tym promieniu (ft)"
},
"always_update_location": {
@@ -1191,7 +1191,7 @@
"description": "Automatycznie potwierdza wybrane działania"
},
"auto_updater": {
"name": "Auto Updater",
"name": "Auto-aktualizator",
"description": "Automatyczne sprawdzanie nowych aktualizacji"
},
"update_settings": {
@@ -1210,7 +1210,7 @@
"name": "Ustawienia interfejsu użytkownika",
"properties": {
"haptic_feedback": {
"name": "Haptic Feedback"
"name": "Haptyczne sprz??enie zwrotne"
}
}
},
@@ -1341,7 +1341,7 @@
"description": "Ilość wątków do użycia"
},
"preset": {
"name": "Preset",
"name": "Ustawienie wst?pne",
"description": "Ustaw prędkość konwersji"
},
"constant_rate_factor": {
@@ -1399,7 +1399,7 @@
"description": "Wyświetla mały podgląd obok niewidocznych Zatrzasków w czacie"
},
"bootstrap_override": {
"name": "Bootstrap Override",
"name": "Nadpisanie bootstrap",
"description": "Przekroczenie ustawień interfejsu użytkownika bootstrap",
"properties": {
"app_appearance": {
@@ -1449,7 +1449,7 @@
"description": "Pokazuje użyteczne informacje o mediach, takie jak data utworzenia w menu kontekstowym przeglądarki operowej"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"name": "Stare selfie Bitmoji",
"description": "Przynosi Bitmoji selfie ze starszych wersji snapchat"
},
"disable_spotlight": {
@@ -1465,7 +1465,7 @@
"description": "Automatycznie zamyka menu Friend Feed po naciśnięciu przycisku nastawienia"
},
"vertical_story_viewer": {
"name": "Vertical Story Viewer",
"name": "Pionowy podgl?d Story",
"description": "Włącza przeglądarkę pionową dla wszystkich historii"
},
"enable_friend_feed_menu_bar": {
@@ -1535,11 +1535,11 @@
"description": "Automatycznie oznacza wiadomości / zatrzaski jako przeczytane nawet wtedy, gdy włączony jest tryb stealth"
},
"mark_snap_as_seen_button": {
"name": "Mark Snap as Seen Button",
"name": "Przycisk oznaczania Snap jako obejrzany",
"description": "Dodaje przycisk, aby zaznaczyć Snap, jak widać podczas oglądania.\nBędzie to działać nawet wtedy, gdy tryb stealth jest włączony"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"name": "Pomijaj przy oznaczaniu jako obejrzane",
"description": "Automatycznie przeskakuje do następnego Snap przy oznaczaniu Snap jak widać.\nUżyj w połączeniu z Mark Snap jako Seen Button"
},
"loop_media_playback": {
@@ -1674,7 +1674,7 @@
},
"snap": {
"name": "Pstryknięcia",
"description": "Make snaps unsaveable"
"description": "Uczy? Snaps niezapisywalne"
},
"external_media": {
"name": "Media zewnętrzne",
@@ -1745,7 +1745,7 @@
"description": "Pozwala na automatyczną odpowiedź w tle. Notatka: To znacznie odsączy akumulator"
},
"cooldown_seconds": {
"name": "Cooldown Seconds",
"name": "Sekundy odnowienia",
"description": "Minimalny czas między auto- odpowiedzi na tę samą rozmowę (w sekundach)"
},
"message_age_threshold": {
@@ -1777,7 +1777,7 @@
"description": "Klucz API do uwierzytelniania w serwisie AI"
},
"ai_system_prompt": {
"name": "AI System Prompt",
"name": "Prompt systemowy AI",
"description": "System prompt, który definiuje osobowość i zachowanie AI"
},
"ai_max_tokens": {
@@ -1847,7 +1847,7 @@
"description": "Automatyczne odpowiedzi dla wiadomości czatu tekstowego"
},
"snap_messages": {
"name": "Snap Replies",
"name": "Odpowiedzi na Snap",
"description": "Automatyczne odpowiedzi na zatrzaski"
},
"story_share_messages": {
@@ -1871,7 +1871,7 @@
"description": "Automatyczne odpowiedzi na naklejki"
},
"tiny_snap_messages": {
"name": "Tiny Snap Replies",
"name": "Odpowiedzi na ma?e Snapy",
"description": "Automatyczne odpowiedzi na małe trzaski"
},
"map_reaction_messages": {
@@ -1895,11 +1895,11 @@
"description": "Pozwala Auto Open Pstryknięcia uruchomić w tle. Notatka: To znacznie odsączy akumulator"
},
"min_delay": {
"name": "Min Delay (ms)",
"name": "Min. op??nienie (ms)",
"description": "Minimalne opóźnienie w milisekundach przed otwarciem zatrzasku"
},
"max_delay_ms": {
"name": "Max Delay (ms)",
"name": "Maks. op??nienie (ms)",
"description": "Maksymalne opóźnienie w milisekundach przed otwarciem zatrzasku"
},
"queue_size": {
@@ -1991,7 +1991,7 @@
"description": "Zatrzymaj tłumaczenie, gdy usługa jest zablokowana"
},
"max_retries": {
"name": "Max Retries",
"name": "Maks. liczba pr?b",
"description": "Maksymalna liczba prób powtórnych"
},
"retry_delay": {
@@ -2112,7 +2112,7 @@
"description": "Cechy doświadczalne",
"properties": {
"native_hooks": {
"name": "Native Hooks",
"name": "Native hooks",
"description": "Niebezpieczne funkcje, które podłączyć do natywnego kodu Snapchat",
"properties": {
"composer_hooks": {
@@ -2180,7 +2180,7 @@
"description": "Override Android ID wysłany do Snapchat",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Podszyj Android ID",
"description": "Przekrocz ID Android wysłany do Snapchat z wartością niestandardową"
},
"custom_android_id": {
@@ -2402,7 +2402,7 @@
"FAMILY_CENTER_ACCEPT": "Zaakceptuj Centrum Rodzinne",
"FAMILY_CENTER_LEAVE": "Centrum Rodzinne",
"STATUS_PLUS_GIFT": "Status Plus prezent",
"TINY_SNAP": "Tiny Snap",
"TINY_SNAP": "Ma?y Snap",
"STATUS_COUNTDOWN": "Odliczanie",
"MAP_REACTION": "Mapa Reakcja",
"chat_messages": "Wiadomości czatowe",
@@ -2412,9 +2412,9 @@
"external_media_messages": "Media zewnętrzne",
"voice_note_messages": "Notatka głosowa",
"sticker_messages": "Naklejka",
"tiny_snap_messages": "Tiny Snap",
"tiny_snap_messages": "Ma?y Snap",
"map_reaction_messages": "Mapa Reakcja",
"half_swipes": "Half Swipes"
"half_swipes": "Po?owiczne przesuni?cia"
},
"media_download_source": {
"none": "Brak",
@@ -2514,8 +2514,8 @@
"actions": {
"remove_friends": "Usuń znajomych",
"clear_conversations": "Wyczyść konwersje",
"clear_friend_feed": "Clear Friend Feed ({count})",
"unfollow": "Unfollow",
"clear_friend_feed": "Wyczy?? kana? znajomych ({count})",
"unfollow": "Przesta? obserwowa?",
"remove": "Usuń"
},
"leave_groups": "Wyjdź{count}grupy",
@@ -2622,7 +2622,7 @@
"snap_replayed": "Snap replained",
"snap_replayed_twice": "Snap ponownie dwukrotnie",
"snap_screenshot": "Zrzut ekranu",
"snap_screen_record": "Snap Screen Record",
"snap_screen_record": "Nagranie ekranu Snap",
"i_can_see_you": "Widzę cię"
},
"cleared_from_feed": "Oczyszczone z paszy",
@@ -2650,7 +2650,7 @@
"dialog_message": "Na pewno chcesz zadzwonić?"
},
"half_swipe_notifier": {
"notification_channel_name": "Half Swipe",
"notification_channel_name": "P??przesuni?cie",
"notification_content_dm": "{friend}po prostu połowicznie wciśnięty do czatu{duration}sekund",
"notification_content_group": "{friend}po prostu połowicznie{group}zamiast{duration}sekund"
},
@@ -2728,7 +2728,7 @@
"error_channel_description": "Powiadomienia o błędach w przypadku awarii automatycznego otwierania",
"paused_status": "Automatyczne otwieranie zamków",
"processing_status": "Przetwarzanie pęknięć:{queued}w kolejce,{processed}przetworzone",
"monitor_status": "Monitoring...",
"monitor_status": "Monitorowanie...",
"recent_snaps": "Ostatnie trzaski",
"action_pause": "Pauza",
"action_resume": "Wznowienie",
@@ -2739,7 +2739,7 @@
"paused_feedback": "Automatyczne otwieranie",
"resumed_message": "Przetwarzanie będzie kontynuowane automatycznie w kolejce zatrzasków",
"paused_message": "Przetwarzanie przerwane. Konserwacja kolejki ({count})",
"status_paused": "Paused",
"status_paused": "Wstrzymane",
"status_monitoring": "Monitorowanie",
"status_active": "Aktywne",
"queue_cleared": "Kolejka oczyszczone i statystyki reset",
@@ -2751,7 +2751,7 @@
"unknown_user": "Nieznany użytkownik",
"content_type_external_media": "Media zewnętrzne",
"content_type_snap": "Snap",
"conversation_type_friend_dm": "Friend DM",
"conversation_type_friend_dm": "DM znajomego",
"conversation_type_dm": "DM",
"conversation_type_group_chat": "Grupa czat",
"conversation_type_chat": "Rozmowa",
@@ -2883,7 +2883,7 @@
"translation_position": {
"above": "Powyżej",
"below": "Poniżej",
"inline": "Inline"
"inline": "W linii"
},
"language_codes": {
"en": "Angielski",
@@ -2939,7 +2939,7 @@
"placeholder": "Szukaj"
},
"filters": {
"newest_first": "Newest first",
"newest_first": "Najnowsze najpierw",
"pick_a_date": "Wybierz datę",
"title": "Filtry",
"search_by": "Szukaj",
@@ -3064,7 +3064,7 @@
}
},
"ui_settings_title": "Ustawienia interfejsu użytkownika",
"haptic_feedback_label": "Haptic Feedback",
"haptic_feedback_label": "Haptyczne sprz??enie zwrotne",
"updates_title": "Aktualizacje",
"auto_update_check": "Automatyczne sprawdzanie aktualizacji",
"update_check_frequency_daily": "Codziennie",
@@ -3111,7 +3111,7 @@
"import_script_from_url": "Importuj skrypt z URL",
"warning_imported_scripts": "Ostrzeżenie: Importowane skrypty mogą być szkodliwe dla urządzenia. Import skryptów tylko z zaufanych źródeł.",
"enter_url_here": "Podaj adres URL tutaj:",
"import": "Import",
"import": "Importuj",
"cancel": "Anuluj",
"documentation": "Dokumentacja"
},
@@ -3127,7 +3127,7 @@
"no_friends_found": "Nie znaleziono przyjaciół",
"exporting_memories": "Eksportowanie wspomnień... ({failed}nieudany)"
},
"clear_friend_feed": "Clear Friend Feed",
"clear_friend_feed": "Wyczy?? kana? znajomych",
"select_date": "Wybierz datę",
"schedule_scheduled_for": "Planowane{name}w{time}",
"schedule_sending_in": "Wysyłanie{time}",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Selecionar idioma",
@@ -182,7 +182,7 @@
"rules_title": "Regras",
"participants_text": "{count}participantes",
"not_found": "Não encontrado",
"streaks_title": "Streaks",
"streaks_title": "Sequ?ncias",
"streaks_length_text": "Comprimento:{length}",
"streaks_expiration_text": "Expira em{eta}",
"streaks_expiration_text_expired": "Expirado",
@@ -305,7 +305,7 @@
"clear_module_data_failed": "Falha ao limpar os dados do módulo",
"delete_module_button": "Apagar",
"delete_module_failed": "Falha ao remover o módulo",
"documentation_button": "Docs",
"documentation_button": "Documenta??o",
"download_script_failed": "Falha ao baixar o script",
"downloading_script": "A transferir o programa...",
"edit_module_button": "Editar",
@@ -716,7 +716,7 @@
"notifications": {
"chat_screenshot": "Imagem",
"chat_screen_record": "Ecrã Gravação",
"snap_replay": "Snap Replay",
"snap_replay": "Reprodu??o de Snap",
"camera_roll_save": "Gravar o Rolo da Câmara",
"chat": "Conversar",
"chat_reply": "Responder à Conversa",
@@ -1021,7 +1021,7 @@
"friendly": "Amigável",
"humorous": "Humoroso",
"empathetic": "Empático",
"toxic": "Edgy",
"toxic": "Agressivo",
"busy": "Ocupado"
},
"ai_temperature": {
@@ -1773,7 +1773,7 @@
"description": "Modelo de IA a usar para gerar respostas (por exemplo, gpt-3.5-turbo, gpt-4)"
},
"ai_api_key": {
"name": "AI API Key",
"name": "Chave da API de IA",
"description": "Chave API para autenticação com o serviço de IA"
},
"ai_system_prompt": {
@@ -1817,7 +1817,7 @@
"description": "Usar mensagens de modelo se IA não gerar uma resposta"
},
"ai_request_timeout": {
"name": "AI Request Timeout",
"name": "Tempo limite da solicita??o de IA",
"description": "Tempo máximo para esperar pela resposta de IA (em segundos)"
},
"ai_retry_attempts": {
@@ -2156,7 +2156,7 @@
}
},
"spoof": {
"name": "Spoof",
"name": "Falsificar",
"description": "Spoof várias informações sobre você",
"properties": {
"play_store_installer_package_name": {
@@ -2180,7 +2180,7 @@
"description": "Sobrescrever o ID Android enviado para o Snapchat",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Falsificar ID do Android",
"description": "Sobrescrever o ID Android enviado para o Snapchat com um valor personalizado"
},
"custom_android_id": {
@@ -2401,7 +2401,7 @@
"FAMILY_CENTER_INVITE": "Convidar para o Centro Familiar",
"FAMILY_CENTER_ACCEPT": "Centro Familiar Aceitar",
"FAMILY_CENTER_LEAVE": "Centro de Família",
"STATUS_PLUS_GIFT": "Status Plus Gift",
"STATUS_PLUS_GIFT": "Presente do Status Plus",
"TINY_SNAP": "Pequeno Snap",
"STATUS_COUNTDOWN": "Contagem regressiva",
"MAP_REACTION": "Reacção do Mapa",
@@ -2552,7 +2552,7 @@
"suggested": "Sugerido",
"deleted": "Apagado",
"business_accounts": "Contas de Negócios",
"streaks": "Streaks",
"streaks": "Sequ?ncias",
"non_streaks": "Não Streaks",
"followed": "Seguido",
"following": "Seguir",
@@ -2562,7 +2562,7 @@
"none": "Nenhum",
"username": "Utilizador",
"added_timestamp": "Marca de tempo adicionada",
"snap_score": "Snap Score",
"snap_score": "Pontua??o do Snap",
"streak_length": "Comprimento do Streak",
"most_messages_sent": "A maioria das mensagens enviadas",
"most_recent_message": "Mensagem mais recente",
@@ -2685,7 +2685,7 @@
}
},
"streaks_reminder": {
"notification_title": "Streaks",
"notification_title": "Sequ?ncias",
"notification_text": "Vais perder o teu Streak com{friend}em{hoursLeft}horas"
},
"biometric_auth": {
@@ -2729,7 +2729,7 @@
"paused_status": "Abrir automaticamente os Snaps pausados",
"processing_status": "Processamento de encaixes:{queued}na fila,{processed}processado",
"monitor_status": "A monitorizar...",
"recent_snaps": "Recent Snaps",
"recent_snaps": "Snaps recentes",
"action_pause": "Pausa",
"action_resume": "Continuar",
"action_clear": "Limpar fila",
@@ -2967,7 +2967,7 @@
"message_reaction_add": "Reacção adicionada",
"message_reaction_remove": "Reacção removida",
"snap_opened": "Abrir o encaixe",
"snap_replayed": "Replayed snap",
"snap_replayed": "Reproduziu um snap",
"snap_replayed_twice": "Replayed snap duas vezes",
"snap_screenshot": "Tirar uma imagem",
"snap_screen_record": "Tela gravada"
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Alegeți limba",
@@ -115,7 +115,7 @@
"available_tabs_title": "Taburi disponibile",
"reset_button": "Reinițializează",
"done_button": "Gata",
"clear_friend_feed": "Clear Friend Feed",
"clear_friend_feed": "Cur??? fluxul de prieteni",
"test_mode_label": "Activează PurrAura",
"disable_feature_loading_label": "Dezactivează încărcarea caracteristicilor",
"disable_auto_mapper_label": "Dezactivează Maper automat",
@@ -663,16 +663,17 @@
"auto_save": "Mesaje de salvare automată",
"unsaveable_messages": " Mesaje nesalvabile",
"auto_open_snaps": "Snaps Open Auto",
"stealth": "👻 Stealth Mode",
"stealth": "?? Mod invizibil",
"auto_reply": "Răspuns automat",
"auto_delete_sent_messages": "Sterge automat mesajele trimise",
"mark_snaps_as_seen": " Mark Snaps așa cum se vede",
"mark_stories_as_seen_locally": " Mark Stories as seen local",
"conversation_info": "Informaţii de conversaţie",
"e2e_encryption": "Utilizați criptarea E2E",
"message_logger": "📝 Message Logger",
"auto_read": "✅ Auto Read",
"hide_typing_indicator": "🙈 Hide Typing Indicator"
"message_logger": "?? Jurnal mesaje",
"auto_read": "? Citire automat?",
"hide_typing_indicator": "?? Ascunde indicatorul de tastare",
"stealth_mode": "?? Mod invizibil"
},
"schedule_scheduled_for": "Programat pentru{name}în{time}",
"schedule_sending_in": "Trimit{time}",
@@ -755,7 +756,8 @@
"stealth": {
"blacklist": "Lista neagră",
"whitelist": "Lista albă",
"disabled": "Dezactivat"
"disabled": "Dezactivat",
"stealth": "?? Mod invizibil"
},
"auto_save": {
"blacklist": "Lista neagră",
@@ -892,13 +894,13 @@
},
"snapchat_plus": {
"not_subscribed": "Neabonat",
"basic": "Basic",
"basic": "De baz?",
"ad_free": "Ad free",
"null": "Implicit"
},
"bypass_video_length_restriction": {
"single": "Medii unice",
"split": "Split media",
"split": "?mparte media",
"null": "Implicit"
},
"old_bitmoji_selfie": {
@@ -909,7 +911,7 @@
"disable_confirmation_dialogs": {
"erase_message": "Șterge mesajul",
"remove_friend": "Elimină prietenul",
"block_friend": "Block Friend",
"block_friend": "Blocheaz? prieten",
"ignore_friend": "Ignoră prietenul",
"hide_friend": "Ascunde prietenul",
"hide_conversation": "Ascunde conversația",
@@ -969,7 +971,7 @@
"auto_mark_as_read": {
"conversation_read": "Marchează conversația ca citită atunci când trimiți un mesaj",
"snap_reply": "Marca se fixează ca citit atunci când răspunde la ele",
"save_snap_in_chat": "Mark plesnește ca citit atunci când le salva în chat în timp ce în Stealth Mode"
"save_snap_in_chat": "Marcheaz? snapurile ca v?zute c?nd le salvezi ?n chat ?n timp ce e?ti ?n Modul invizibil"
},
"friend_mutation_notifier": {
"remove_friend": "Anunţă când cineva te elimină ca prieten",
@@ -1016,7 +1018,7 @@
"friendly, casual, helpful, empathetic": "prietenos, ocazional, util, empatic"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Relaxat",
"formal": "Formal",
"friendly": "Prietenos",
"humorous": "Umor",
@@ -1092,7 +1094,7 @@
"sticker_messages": "Autocolante",
"tiny_snap_messages": "Snaps mici",
"map_reaction_messages": "Hartă Reacţii",
"half_swipes": "Half Swipes"
"half_swipes": "Glis?ri pe jum?tate"
},
"supported_languages": {
"en": "Engleză",
@@ -1112,7 +1114,7 @@
"translation_position": {
"above": "Deasupra textului",
"below": "Sub text",
"inline": "Inline"
"inline": "?n linie"
},
"source_language": {
"auto": "Detectează automat"
@@ -1139,7 +1141,7 @@
"description": "Setează coordonatele locației spoofed"
},
"walk_radius": {
"name": "Walk Radius",
"name": "Raz? de mers",
"description": "Plimbare aleatoare în jurul valorii de pe această rază (ft)"
},
"always_update_location": {
@@ -1173,7 +1175,7 @@
"description": "Suprascrie calitatea de încărcare media",
"properties": {
"force_video_upload_source_quality": {
"name": "Force Video Upload Source Quality",
"name": "For?eaz? calitatea sursei de ?nc?rcare video",
"description": "Forţează Snapchat să folosească calitatea sursei la încărcarea videoclipurilor\nVă rugăm să rețineți că acest lucru nu poate elimina metadatele din mass-media"
},
"disable_image_compression": {
@@ -1341,7 +1343,7 @@
"description": "Cantitatea de fire de utilizat"
},
"preset": {
"name": "Preset",
"name": "Presetare",
"description": "Setează viteza de conversie"
},
"constant_rate_factor": {
@@ -1349,7 +1351,7 @@
"description": "Setează factorul de rată constantă pentru codorul video\nDe la 0 la 51 pentru libx264"
},
"video_bitrate": {
"name": "Video Bitrate",
"name": "Bitrate video",
"description": "Setează rata de biți video (kbps)"
},
"audio_bitrate": {
@@ -1449,7 +1451,7 @@
"description": "Arată informații utile ale mass-media, cum ar fi data creării în meniul contextului vizualizatorului operei"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"name": "Selfie Bitmoji vechi",
"description": "Aduce înapoi selfie-urile Bitmoji din versiunile vechi Snapchat"
},
"disable_spotlight": {
@@ -1847,11 +1849,11 @@
"description": "Mesaje de răspuns automat pentru mesajele de chat text"
},
"snap_messages": {
"name": "Snap Replies",
"name": "R?spunsuri la Snap",
"description": "Mesaje de răspuns automat pentru capturi"
},
"story_share_messages": {
"name": "Story Share Replies",
"name": "R?spunsuri la partaj?ri de Story",
"description": "Mesaje de răspuns automat pentru acțiuni de poveste"
},
"story_reply_messages": {
@@ -1991,7 +1993,7 @@
"description": "Pauză traducere atunci când serviciul este blocat"
},
"max_retries": {
"name": "Max Retries",
"name": "Num?r maxim de ?ncerc?ri",
"description": "Numărul maxim de încercări de rejudecare"
},
"retry_delay": {
@@ -2124,7 +2126,7 @@
"description": "Arată primul nume de utilizator creat lângă numele de utilizator curent din pagina de profil"
},
"bypass_camera_roll_limit": {
"name": "Bypass Camera Roll Limit",
"name": "Ocole?te limita rolei foto",
"description": "Crește cantitatea maximă de mass-media pe care o puteți trimite din rola camerei"
},
"custom_self_destruct_snap_delay": {
@@ -2160,7 +2162,7 @@
"description": "Spoof diverse informații despre tine",
"properties": {
"play_store_installer_package_name": {
"name": "Play Store Installer Package Name",
"name": "Nume pachet instalator Play Store",
"description": "Suprascrie numele pachetului instalatorului la com.android.vending"
},
"remove_vpn_transport_flag": {
@@ -2260,7 +2262,7 @@
"description": "Încercarea de a repara meniul de alimentare Friend ca atunci când dispozitivul este offline nu poate fi afișat corect"
},
"app_lock": {
"name": "App Lock",
"name": "Blocare aplica?ie",
"description": "Previne accesul la Snapchat fără cod de acces",
"properties": {
"lock_on_resume": {
@@ -2380,7 +2382,7 @@
"preview": "Previzualizare",
"stealth_mode": "Mod ascuns",
"auto_download_blacklist": "Descărcare automată Lista neagră",
"anti_auto_save": "Anti Auto Save"
"anti_auto_save": "Anti salvare automat?"
},
"content_type": {
"CHAT": "Discută",
@@ -2400,7 +2402,7 @@
"CREATIVE_TOOL_ITEM": "Element instrument creativ",
"FAMILY_CENTER_INVITE": "Family Center Invită",
"FAMILY_CENTER_ACCEPT": "Centrul de Familie Acceptă",
"FAMILY_CENTER_LEAVE": "Family Center Leave",
"FAMILY_CENTER_LEAVE": "P?r?se?te Family Center",
"STATUS_PLUS_GIFT": "Stare Plus cadou",
"TINY_SNAP": "Snap mic",
"STATUS_COUNTDOWN": "Numărătoarea inversă",
@@ -2414,7 +2416,7 @@
"sticker_messages": "Autocolant",
"tiny_snap_messages": "Snap mic",
"map_reaction_messages": "Reacţie pe hartă",
"half_swipes": "Half Swipes"
"half_swipes": "Glis?ri pe jum?tate"
},
"media_download_source": {
"none": "Niciuna",
@@ -2619,8 +2621,8 @@
"message_reaction_add": "Reacție mesaj Adaugă",
"message_reaction_remove": "Elimină reacția mesajului",
"snap_opened": "Snap Deschis",
"snap_replayed": "Snap Replayed",
"snap_replayed_twice": "Snap Replayed Twice",
"snap_replayed": "Snap reluat",
"snap_replayed_twice": "Snap reluat de dou? ori",
"snap_screenshot": "Captură de ecran",
"snap_screen_record": "Snap înregistrare ecran",
"i_can_see_you": "Te pot vedea"
@@ -2650,7 +2652,7 @@
"dialog_message": "Sigur vrei să începi un apel?"
},
"half_swipe_notifier": {
"notification_channel_name": "Half Swipe",
"notification_channel_name": "Glisare pe jum?tate",
"notification_content_dm": "{friend}doar pe jumătate băgat în discuţia ta pentru{duration}secunde",
"notification_content_group": "{friend}doar pe jumătate lovit în{group}pentru{duration}secunde"
},
@@ -2759,7 +2761,7 @@
"notification_statistics": "STATISTICI",
"notification_queue_size": "Mărime coadă",
"notification_total_opened": "Total capturi deschise",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_queue_preview": "PREVIZUALIZARE COAD?",
"notification_processing_continue": "Procesarea va continua automat...",
"notification_no_snaps_queue": "Fără pocniri la coadă.",
"notification_queue_cleared_opened": "Coada este închisă ({opened}deschis)",
@@ -2883,7 +2885,7 @@
"translation_position": {
"above": "Deasupra",
"below": "Mai jos",
"inline": "Inline"
"inline": "?n linie"
},
"language_codes": {
"en": "Engleză",
@@ -3127,7 +3129,7 @@
"no_friends_found": "Nu am găsit niciun prieten",
"exporting_memories": "Export amintiri... ({failed}a eșuat)"
},
"clear_friend_feed": "Clear Friend Feed",
"clear_friend_feed": "Cur??? fluxul de prieteni",
"select_date": "Alegeți data",
"schedule_scheduled_for": "Programat pentru{name}în{time}",
"schedule_sending_in": "Trimit{time}",
@@ -3182,7 +3184,7 @@
"failed_to_edit_message": "Eșec la editarea mesajului:{error}"
},
"ai_response_style": {
"casual": "Casual",
"casual": "Relaxat",
"formal": "Formal",
"friendly": "Prietenos",
"humorous": "Umor",
@@ -3218,4 +3220,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Выберите язык",
@@ -97,7 +97,7 @@
"update_notification_channel_description": "Получить уведомление, когда новые релизы доступны",
"update_notification_title": "Новое обновление доступно",
"update_notification_text": "Нажмите, чтобы открыть PurrfectSnap и загрузить последнюю сборку.",
"app_theme_title": "App Theme",
"app_theme_title": "???? ??????????",
"theme_icon_description": "Выбор открытой темы",
"theme_mode_system": "Система",
"theme_mode_light": "Свет",
@@ -532,11 +532,11 @@
}
},
"auto_open_snaps": {
"name": "Auto Open Snaps",
"name": "???????????? ??????",
"description": "Автоматически открывает Snaps при их получении",
"options": {
"blacklist": "Скачать Auto Open Snaps",
"whitelist": "Auto Open Snaps"
"whitelist": "???????????? ??????"
}
},
"hide_friend_feed": {
@@ -611,7 +611,7 @@
"description": "Экспорт памяти в ZIP-файл"
},
"bulk_messaging_action": {
"name": "Bulk Messaging Action",
"name": "???????? ???????? ?????????",
"description": "Выполняет такие операции, как удаление друзей или массовое удаление разговоров"
},
"regen_mappings": {
@@ -833,7 +833,7 @@
"null": "Кодек по умолчанию"
},
"preset": {
"null": "Default Preset"
"null": "????????????? ?? ?????????"
},
"app_appearance_override": {
"title": "Внешний вид"
@@ -893,7 +893,7 @@
"snapchat_plus": {
"not_subscribed": "Не подписывается",
"basic": "Базовый",
"ad_free": "Ad Free",
"ad_free": "??? ???????",
"null": "По умолчанию"
},
"bypass_video_length_restriction": {
@@ -1395,7 +1395,7 @@
}
},
"snap_preview": {
"name": "Snap Preview",
"name": "???????????? ?????",
"description": "Отображает небольшой предварительный просмотр рядом с невидимыми Snaps в чате"
},
"bootstrap_override": {
@@ -1597,7 +1597,7 @@
"description": "Показывает предварительный просмотр полученных сообщений в уведомлении"
},
"media_preview": {
"name": "Media Preview",
"name": "???????????? ?????",
"description": "Показывает предварительный просмотр выбранных типов медиа в уведомлении"
},
"media_caption": {
@@ -1761,7 +1761,7 @@
"description": "Используйте ИИ для создания интеллектуальных автоматических реплик вместо шаблонных сообщений"
},
"ai_provider": {
"name": "AI Provider",
"name": "????????? ??",
"description": "Выберите, какой сервис ИИ использовать для генерации ответов"
},
"ai_endpoint_url": {
@@ -2260,7 +2260,7 @@
"description": "Попробуйте отремонтировать меню Friend Feed, так как когда устройство отключено, оно не может отображаться правильно"
},
"app_lock": {
"name": "App Lock",
"name": "?????????? ??????????",
"description": "Предотвращение доступа к Snapchat без пароля",
"properties": {
"lock_on_resume": {
@@ -2396,7 +2396,7 @@
"STATUS_CONVERSATION_CAPTURE_RECORD": "Запись с экрана",
"STATUS_CALL_MISSED_VIDEO": "Пропущенный видеозвонок",
"STATUS_CALL_MISSED_AUDIO": "Пропущенный аудиозвонок",
"LIVE_LOCATION_SHARE": "Live Location Share",
"LIVE_LOCATION_SHARE": "????? ?????? ? ?????????? ? ???????? ???????",
"CREATIVE_TOOL_ITEM": "Креативный инструмент",
"FAMILY_CENTER_INVITE": "Семейный центр приглашает",
"FAMILY_CENTER_ACCEPT": "Семейный центр принимает",
@@ -2720,7 +2720,7 @@
"incoming_secret_message": "Твой друг только что принял твой открытый ключ. Нажмите ниже, чтобы принять секрет."
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"title": "???????????? ??????",
"priority_title": "Auto Open Snaps (приоритет)",
"error_title": "Auto Open Snaps (ошибки)",
"channel_description": "Уведомления о статусе очереди после автоматического открытия",
@@ -2842,7 +2842,7 @@
},
"material3_strings": {
"date_range_picker_start_headline": "Из",
"date_range_picker_end_headline": "To",
"date_range_picker_end_headline": "????",
"date_range_picker_title": "Выберите диапазон дат",
"date_picker_switch_to_calendar_mode": "Расписание",
"date_picker_switch_to_input_mode": "Ввод",
@@ -3051,7 +3051,7 @@
"clear": "Чисто",
"files": {
"config_json": "Конфигурационный файл",
"mappings_json": "Mappings File",
"mappings_json": "???? ?????????????",
"message_logger_db": "База данных Message Logger",
"pinned_best_friend_txt": "Файл лучшего друга",
"native_sig_cache_txt": "Родная подпись Файл кэша"
@@ -3074,7 +3074,7 @@
"update_channel_prerelease": "Предварительный выпуск",
"friend_notes_title": "Записки друзей",
"friend_notes_description": "Управление и резервное копирование заметок друзей",
"app_theme_title": "App Theme",
"app_theme_title": "???? ??????????",
"theme_mode_system": "Система",
"theme_mode_light": "Свет",
"theme_mode_dark": "Темный",
@@ -3170,7 +3170,7 @@
},
"debug_dialogs": {
"info": "Информация",
"refs": "Refs",
"refs": "??????",
"arroyo": "Арро",
"message": "Послание",
"media_references": "Ссылки на СМИ",
@@ -3218,4 +3218,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"dialogs": {
"select_language": "Izberi jezik",
@@ -546,7 +546,7 @@
"name": "Uporabi šifriranje E2E"
},
"pin_conversation": {
"name": "Pin Conversation"
"name": "Pripni pogovor"
},
"exclude_message_logger": {
"name": "Izbriši iz dnevnika sporočil"
@@ -659,20 +659,21 @@
"null": "Uporabi pravo raven baterije"
},
"friend_feed_menu_buttons": {
"auto_download": "⬇️ Auto Download",
"auto_save": "💬 Auto Save Messages",
"unsaveable_messages": " Nerešljiva sporočila",
"auto_open_snaps": "📷 Auto Open Snaps",
"stealth": "👻 Stealth Mode",
"auto_reply": "📨 Auto Reply",
"auto_delete_sent_messages": "🗑️ Auto Delete Sent Messages",
"mark_snaps_as_seen": " Mark Snaps, kot se vidi",
"mark_stories_as_seen_locally": " Označite zgodbe, kot jih vidite na lokalni ravni",
"conversation_info": "👤 Conversation Info",
"e2e_encryption": "🔒 Use E2E Encryption",
"message_logger": "📝 Message Logger",
"auto_read": "✅ Auto Read",
"hide_typing_indicator": "🙈 Hide Typing Indicator"
"auto_download": "?? Samodejni prenos",
"auto_save": "?? Samodejno shranjevanje sporo?il",
"unsaveable_messages": "Nezapisljiva sporo?ila",
"auto_open_snaps": "?? Samodejno odpiranje Snapov",
"stealth": "?? Prikriti na?in",
"auto_reply": "?? Samodejni odgovor",
"auto_delete_sent_messages": "??? Samodejno brisanje poslanih sporo?il",
"mark_snaps_as_seen": "Ozna?i Snape kot ogledane",
"mark_stories_as_seen_locally": "Ozna?i Stories kot ogledane lokalno",
"conversation_info": "?? Informacije o pogovoru",
"e2e_encryption": "?? Uporabi E2E ?ifriranje",
"message_logger": "?? Bele?enje sporo?il",
"auto_read": "? Samodejno branje",
"hide_typing_indicator": "?? Skrij indikator tipkanja",
"auto_save_messages": "?? Samodejno shranjevanje sporo?il"
},
"schedule_scheduled_for": "Načrtovano za{name}v{time}",
"schedule_sending_in": "Pošiljanje noter{time}",
@@ -760,7 +761,8 @@
"auto_save": {
"blacklist": "Črna lista",
"whitelist": "Bela lista",
"disabled": "Onemogočeno"
"disabled": "Onemogočeno",
"auto_save": "?? Samodejno shranjevanje sporo?il"
},
"message_logger": {
"blacklist": "Črna lista",
@@ -1777,7 +1779,7 @@
"description": "API ključ za preverjanje pristnosti z AI storitev"
},
"ai_system_prompt": {
"name": "AI System Prompt",
"name": "Poziv sistema AI",
"description": "Sistem, ki določa osebnost in vedenje AI"
},
"ai_max_tokens": {
@@ -1991,7 +1993,7 @@
"description": "Prekini prevajanje, ko je storitev blokirana"
},
"max_retries": {
"name": "Max Retries",
"name": "Najve? ponovitev",
"description": "Največje število poskusov ponovnega preskušanja"
},
"retry_delay": {
@@ -2553,7 +2555,7 @@
"deleted": "Zbrisano",
"business_accounts": "Poslovni računi",
"streaks": "Streaks",
"non_streaks": "Non Streaks",
"non_streaks": "Brez streakov",
"followed": "Sledi",
"following": "Sledi",
"location_on_map": "Lokacija na zemljevidu"
@@ -2562,7 +2564,7 @@
"none": "Brez",
"username": "Uporabniško ime",
"added_timestamp": "Dodan časovni žig",
"snap_score": "Snap Score",
"snap_score": "Snap to?ke",
"streak_length": "Dolžina streak",
"most_messages_sent": "Večina poslanih sporočil",
"most_recent_message": "Najnovejše sporočilo",
@@ -3218,4 +3220,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -702,7 +702,7 @@
"append_type": "Додавання медіа типу до імені файлу"
},
"auto_download_sources": {
"friend_snaps": "Friend Snaps",
"friend_snaps": "Снепи друзів",
"friend_stories": "Історія",
"public_stories": "Громадські історії",
"spotlight": "Мапа"
@@ -2180,7 +2180,7 @@
"description": "Ознайомитися з Android ID, надісланим на Snapchat",
"properties": {
"spoof_android_id": {
"name": "Spoof Android ID",
"name": "Підмінити Android ID",
"description": "На відміну від Android ID, відправленого на Snapchat з індивідуальним значенням"
},
"custom_android_id": {
@@ -2629,7 +2629,7 @@
"tracker_actions": {
"log": "Увійти",
"in_app_notification": "Повідомлення",
"push_notification": "Push Notification",
"push_notification": "Push-сповіщення",
"custom": "Написати"
},
"better_notifications": {
@@ -3218,4 +3218,4 @@
"openai": "Про компанію",
"openrouter": "Проксимус"
}
}
}

View File

@@ -2,6 +2,6 @@ package me.eternal.purrfectsnap.common
object Constants {
val SNAPCHAT_PACKAGE_NAME get() = "com.snapchat.android"
val SE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID
val MODULE_PACKAGE_NAME get() = BuildConfig.APPLICATION_ID
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.3"
}
}

View File

@@ -18,7 +18,7 @@ enum class FileHandleScope(
INTERNAL("internal"),
LOCALE("locale"),
USER_IMPORT("user_import"),
COMPOSER("composer");
VALDI("valdi");
companion object {
fun fromValue(name: String): FileHandleScope? = entries.find { it.key == name }

View File

@@ -35,16 +35,19 @@ class ModConfig(
fun load() {
wasPresent = fileWrapper.exists()
root = createRootConfig().apply {
if (!wasPresent) {
writeConfigObject(this)
return@apply
}
runCatching {
loadConfig(this)
}.onFailure {
writeConfigObject(this)
}
val targetRoot = if (::root.isInitialized) {
root
} else {
createRootConfig().also { root = it }
}
if (!wasPresent) {
writeConfigObject(targetRoot)
return
}
runCatching {
loadConfig(targetRoot)
}.onFailure {
writeConfigObject(targetRoot)
}
}
@@ -136,4 +139,4 @@ class ModConfig(
root.fromJson(configObject)
writeConfig()
}
}
}

View File

@@ -24,16 +24,16 @@ class Experimental : ConfigContainer() {
val notificationTranscript = boolean("notification_transcript") { requireRestart() }
}
class ComposerHooksConfig: ConfigContainer(hasGlobalState = true) {
class ValdiHooksConfig: ConfigContainer(hasGlobalState = true) {
val showFirstCreatedUsername = boolean("show_first_created_username")
val bypassCameraRollLimit = boolean("bypass_camera_roll_limit")
val customSelfDestructSnapDelay = boolean("custom_self_destruct_snap_delay")
val composerConsole = boolean("composer_console")
val composerLogs = boolean("composer_logs")
val valdiConsole = boolean("composer_console")
val valdiLogs = boolean("composer_logs")
}
class NativeHooks : ConfigContainer() {
val composerHooks = container("composer_hooks", ComposerHooksConfig()) { requireRestart() }
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
val disableBitmoji = boolean("disable_bitmoji")
val customEmojiFont = string("custom_emoji_font") {
requireRestart()

View File

@@ -61,10 +61,12 @@ class Global : ConfigContainer() {
inner class UpdateSettings : ConfigContainer() {
val autoUpdateCheck = boolean("auto_update_check")
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
val updateChannel = unique("update_channel", "stable", "prerelease")
}
inner class UISettings : ConfigContainer() {
val hapticFeedback = boolean("haptic_feedback", true)
val useSystemToasts = boolean("use_system_toasts", false)
}
val updateSettings = container("update_settings", UpdateSettings()) { addFlags(ConfigFlag.HIDDEN) }

View File

@@ -67,7 +67,7 @@ class MessagingTweaks : ConfigContainer() {
inner class AiConfig : ConfigContainer(hasGlobalState = false) {
val enableAiReplies = boolean("enable_ai_replies", false)
val aiProvider = unique("ai_provider", "gemini", "deepseek", "openai") {
val aiProvider = unique("ai_provider", "gemini", "deepseek", "openai", "openrouter") {
customOptionTranslationPath = "ai_provider"
}.apply { set("gemini") }
val aiModel = string("ai_model", defaultValue = "gemini-2.5-flash") {
@@ -159,10 +159,6 @@ class MessagingTweaks : ConfigContainer() {
}
class AutoOpenSnapsConfig : ConfigContainer(hasGlobalState = true) {
init {
globalState = false
}
val allowRunningInBackground = boolean("allow_running_in_background", false)
val minDelay = integer("min_delay", defaultValue = 50) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
@@ -223,6 +219,19 @@ class MessagingTweaks : ConfigContainer() {
"EXTERNAL_MEDIA",
"STICKER"
) { requireRestart(); customOptionTranslationPath = "content_type" }
class UnsaveableMessagesConfig : ConfigContainer() {
val chat = boolean("chat", defaultValue = true)
val snap = boolean("snap")
val externalMedia = boolean("external_media")
val sticker = boolean("sticker")
val share = boolean("share")
val note = boolean("note")
val storyReply = boolean("story_reply")
}
val unsaveableMessages = container("unsaveable_messages", UnsaveableMessagesConfig()) { requireRestart() }
val preventMessageSending = multiple("prevent_message_sending", *NotificationType.getOutgoingValues().map { it.key }.toTypedArray()) {
customOptionTranslationPath = "features.options.notifications"
}
@@ -239,7 +248,16 @@ class MessagingTweaks : ConfigContainer() {
customOptionTranslationPath = "features.options.notifications"
}
val messageLogger = container("message_logger", MessageLoggerConfig()) { requireRestart() }
val galleryMediaSendOverride = unique("gallery_media_send_override", "always_ask", "SNAP", "NOTE", "SAVEABLE_SNAP") { requireRestart() }
class GalleryMediaSendOverrideConfig : ConfigContainer() {
val mode = unique("mode", "always_ask", "SNAP", "NOTE", "SAVEABLE_SNAP") {
requireRestart()
customOptionTranslationPath = "gallery_media_send_override"
}
val includeCameraSnaps = boolean("include_camera_snaps", false) { requireRestart() }
}
val galleryMediaSendOverride = container("gallery_media_send_override", GalleryMediaSendOverrideConfig()) { requireRestart() }
val scheduledSendAllowRunningInBackground = boolean("scheduled_send_allow_running_in_background", false)
val stripMediaMetadata = multiple("strip_media_metadata", "hide_caption_text", "hide_snap_filters", "hide_extras", "remove_audio_note_duration", "remove_audio_note_transcript_capability") { requireRestart() }
val bypassMessageRetentionPolicy = boolean("bypass_message_retention_policy") { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
@@ -286,3 +304,4 @@ class MessagingTweaks : ConfigContainer() {
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
}

View File

@@ -4,15 +4,19 @@ import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
class Spoof : ConfigContainer(hasGlobalState = true) {
inner class SpoofDeviceIdConfig : ConfigContainer() {
val spoofAndroidId = boolean("spoof_android_id") { requireRestart() }
val customAndroidId = string("custom_android_id") {
requireRestart()
inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) }
}
}
val overridePlayStoreInstallerPackageName = boolean("play_store_installer_package_name") { requireRestart() }
val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() }
val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() }
val forceWifiTransportFlag = boolean("force_wifi_transport_flag") { requireRestart() }
val spoofAndroidId = boolean("spoof_android_id") { requireRestart() }
val customAndroidId = string("custom_android_id") {
requireRestart()
inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) }
}
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
val spoofDevice = boolean("spoof_device") { requireRestart() }
val deviceModel = unique("device_model",
"samsung_s25_ultra",

View File

@@ -57,7 +57,7 @@ enum class MessagingRuleType(
HIDE_FRIEND_FEED("hide_friend_feed", false, Icons.Outlined.VisibilityOff, showInFriendMenu = false),
E2E_ENCRYPTION("e2e_encryption", false, Icons.Outlined.Lock),
PIN_CONVERSATION("pin_conversation", false, Icons.Outlined.PushPin, showInFriendMenu = false),
MESSAGE_LOGGER("message_logger", true, Icons.AutoMirrored.Filled.Message, showInFriendMenu = true),
MESSAGE_LOGGER("message_logger", true, Icons.AutoMirrored.Filled.Message, showInFriendMenu = true, defaultValue = "blacklist"),
AUTO_READ("auto_read", true, Icons.Outlined.DoneAll, defaultValue = "whitelist"),
AUTO_REPLY("auto_reply", true, Icons.AutoMirrored.Outlined.Reply, defaultValue = "blacklist"),
AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist");

View File

@@ -52,7 +52,8 @@ class JSModule(
putConst("description", this, moduleInfo.description)
putConst("author", this, moduleInfo.author)
putConst("minSnapchatVersion", this, moduleInfo.minSnapchatVersion)
putConst("minSEVersion", this, moduleInfo.minSEVersion)
putConst("minPSVersion", this, moduleInfo.minPSVersion)
putConst("minSEVersion", this, moduleInfo.minPSVersion)
putConst("grantedPermissions", this, moduleInfo.grantedPermissions)
})
})
@@ -311,4 +312,4 @@ class JSModule(
}
} ?: "null"
}
}
}

View File

@@ -58,8 +58,8 @@ open class ScriptRuntime(
val bufferedReader = content.bufferedReader()
val moduleInfo = bufferedReader.readModuleInfo()
if (moduleInfo.minSEVersion != null && moduleInfo.minSEVersion > BuildConfig.VERSION_CODE) {
throw Exception("Module requires a newer version of PurrfectSnap (min version: ${moduleInfo.minSEVersion})")
if (moduleInfo.minPSVersion != null && moduleInfo.minPSVersion > BuildConfig.VERSION_CODE) {
throw Exception("Module requires a newer version of PurrfectSnap (min version: ${moduleInfo.minPSVersion})")
}
return JSModule(
@@ -73,4 +73,4 @@ open class ScriptRuntime(
modules[scriptPath] = this
}
}
}
}

View File

@@ -10,7 +10,7 @@ data class ModuleInfo(
val updateUrl: String? = null,
val author: String? = null,
val minSnapchatVersion: Long? = null,
val minSEVersion: Long? = null,
val minPSVersion: Long? = null,
val grantedPermissions: List<String>,
val executionSides: List<String>? = null,
) {
@@ -23,14 +23,14 @@ data class ModuleInfo(
fun BufferedReader.readModuleInfo(): ModuleInfo {
val header = readLine()
if (!header.startsWith("// ==SE_module==")) {
if (!header.startsWith("// ==PS_module==") && !header.startsWith("// ==SE_module==")) {
throw Exception("Invalid module header")
}
val properties = mutableMapOf<String, String>()
while (true) {
val line = readLine()
if (line.startsWith("// ==/SE_module==")) {
if (line.startsWith("// ==/PS_module==") || line.startsWith("// ==/SE_module==")) {
break
}
val split = line.replaceFirst("//", "").split(":", limit = 2)
@@ -52,7 +52,8 @@ fun BufferedReader.readModuleInfo(): ModuleInfo {
updateUrl = properties["updateUrl"],
author = properties["author"],
minSnapchatVersion = properties["minSnapchatVersion"]?.toLongOrNull(),
minSEVersion = properties["minSEVersion"]?.toLongOrNull(),
minPSVersion = properties["minPSVersion"]?.toLongOrNull()
?: properties["minSEVersion"]?.toLongOrNull(),
grantedPermissions = properties["permissions"]?.split(",")?.map { it.trim() } ?: emptyList(),
executionSides = properties["executionSides"]?.lowercase()?.split(",")?.map { it.trim() },
)

View File

@@ -1,7 +0,0 @@
export default {
input: "./build/typescript/main.js",
output: {
file: "./build/loader.js",
format: "iife",
}
};

View File

@@ -1,19 +0,0 @@
import { defineModule } from "../types";
import { interceptComponent } from "../utils";
export default defineModule({
name: "Bypass Camera Roll Selection Limit",
enabled: config => config.bypassCameraRollLimit,
init() {
interceptComponent(
'memories_ui/src/clickhandlers/MultiSelectClickHandler',
'MultiSelectClickHandler',
{
"<init>": (args: any[], superCall: () => void) => {
args[1].selectionLimit = 9999999;
superCall();
}
}
)
}
});

View File

@@ -1,23 +1,35 @@
{
"allowed_eps_active": [
"/messagingcoreservice.MessagingCoreService/",
"/GetConvoSafetyPrompt",
"/GetSnapchatterPublicInfo",
"/UserRecentlyActive",
"/socialsms.SocialSms/UpdateLink",
"/snapchat.friending.server.RecentlyActive/UserRecentlyActive",
"/snapchat.friending.server.ContactBook/",
"/snapchat.friending.server.FriendRequests/",
"/snapchat.friending.server.FriendAction/",
"/com.snapchat.atlas.gw.AtlasGw/SyncFriendData",
"/com.snapchat.atlas.gw.AtlasGw/GetFriendsUserScore",
"/com.snapchat.atlas.gw.AtlasGw/GetUserRecentlyActive",
"/snapchat.friending.server.FriendAction/",
"/snapchat.friending.server.FriendRequests/",
"/com.snapchat.atlas.gw.AtlasGw/SetUserDisplayName",
"/snapchat.music.music_service.MusicService/",
"/snapchat.music.external_music_service.ExternalMusicService/",
"/snapchat.content.v2.MediaDeliveryService/",
"/snapchat.content.v2.MediaOriginService/",
"/snapchat.map.",
"/snapchat.notif.DeviceStateReceiver/",
"/snapchat.creativetools.",
"/snapchat.lens.",
"/snapchat.bitmoji.",
"/snapchat.aura.api.AuraService/",
"/com.snapchat.ads.notification.ProfileNotificationCRUDService/",
"ClientIntegrityService",
"/ClientIntegrityService",
"/ClientIntegrityService/",
"/GetAttestationHeaders",
"/AttestationHeaders",
"/getAttestationHeaders",
"/GetAttestationPayload",
"/AttestationPayload"
"/com.snapchat.commerce.",
"/snapchat.payments.commerce.",
"/snapchat.abuse.support.AppealService/",
"/snapchat.local.snapzen.userdata.",
"/snapchat.valis.Valis/",
"com.snap.identity.network.suggestion.BqSuggestFriendHttpInterface.fetchHighQualitySuggestedFriend",
"/snap.security.ArgosService/GetTokens",
"com.snap.playstate.net.ReadReceiptHttpInterface.batchUploadReadReceipts",
"com.snap.identity.network.suggestion.BqSuggestFriendHttpInterface.fetchLegacySuggestedFriend",
"com.snap.identity.FriendingHttpInterface.submitSuggestedFriendsAction"
]
}

View File

@@ -35,7 +35,7 @@ dependencies {
implementation(project(":common"))
implementation(project(":mapper"))
implementation(project(":native"))
implementation(project(":composer"))
implementation(project(":valdi"))
implementation(libs.androidx.activity.ktx)
implementation(platform(libs.androidx.compose.bom))
@@ -45,4 +45,5 @@ dependencies {
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3)
implementation(libs.hiddenapibypass)
}
implementation(libs.colorpicker.compose)
}

View File

@@ -114,6 +114,7 @@
justify-content: flex-start;
flex-wrap: nowrap;
margin: 5px 15px;
--sender-color: #ffd3b6;
}
main>.message .header {
@@ -127,16 +128,9 @@
align-items: center;
}
main>.message:nth-child(2n) .username {
color: #dcedc1;
}
main>.message:nth-child(2n + 1) .username {
color: #ffd3b6;
}
main>.message .username {
font-weight: bold;
color: var(--sender-color);
}
main>.message .time {
@@ -145,14 +139,6 @@
font-weight: 600;
}
main>.message:nth-child(2n) .content {
border-color: #dcedc1;
}
main>.message:nth-child(2n + 1) .content {
border-color: #ffd3b6;
}
main>.message .content {
background-color: var(--Snap-sigBackgroundMessageSaved);
border-left: 3px solid;
@@ -160,6 +146,7 @@
margin-top: 4px;
padding-left: 4px;
padding: 3px 0 3px 6px;
border-color: var(--sender-color);
}
main>.message .content div:has(.chat_media:not(audio):not(.overlay_media)) {
@@ -203,6 +190,17 @@
main>.message .red_snap_svg {
color: var(--Snap-sigSnapWithoutSound);
}
main>.message .time.with_deleted {
display: inline-flex;
align-items: center;
gap: 6px;
}
main>.message .deleted_icon_svg {
width: 12px;
height: 12px;
color: var(--Snap-sigTextNegative);
}
</style>
<body>
<header>
@@ -220,6 +218,9 @@
<svg class="red_snap_svg" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="5" width="10.5" height="10.5" rx="1.808" stroke="currentColor" stroke-width="1.5"></rect>
</svg>
<svg class="deleted_icon_svg" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 3h6M5 7h14M6 7l1 14h10l1-14M10 10v7M14 10v7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</div>
<script>
@@ -251,6 +252,52 @@
}
}
function hexToRgb(hex) {
if (!hex) return null
let value = hex.replace("#", "")
if (value.length === 3) {
value = value.split("").map(c => c + c).join("")
}
if (value.length !== 6) return null
const number = parseInt(value, 16)
return {
r: (number >> 16) & 255,
g: (number >> 8) & 255,
b: number & 255
}
}
function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
const delta = max - min
if (delta !== 0) {
s = delta / (1 - Math.abs(2 * l - 1))
switch (max) {
case r:
h = ((g - b) / delta) % 6
break
case g:
h = (b - r) / delta + 2
break
default:
h = (r - g) / delta + 4
break
}
h = Math.round(h * 60)
if (h < 0) h += 360
}
return { h, s: Math.round(s * 100), l: Math.round(l * 100) }
}
function makeMain() {
document.querySelector('main').innerHTML = ""
const messageTemplate = document.querySelector("#message_template")
@@ -260,9 +307,34 @@
messageList = messageList.reverse()
}
const userColors = new Map()
const userColorsOverride = conversationData.userColors || {}
const seedRgb = hexToRgb(conversationData.colorSeed)
const baseHue = seedRgb ? rgbToHsl(seedRgb.r, seedRgb.g, seedRgb.b).h : null
function colorForUser(userId) {
if (userColors.has(userId)) return userColors.get(userId)
if (userColorsOverride[userId]) {
userColors.set(userId, userColorsOverride[userId])
return userColorsOverride[userId]
}
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash) + userId.charCodeAt(i)
hash |= 0
}
const hue = baseHue === null ? (Math.abs(hash) % 360) : ((baseHue + Math.abs(hash)) % 360)
const color = `hsl(${hue}, 58%, 72%)`
userColors.set(userId, color)
return color
}
messageList.forEach(message => {
const messageObject = document.createElement("div")
messageObject.classList.add("message")
const participant = participants[message.senderId]
const participantUserId = participant ? participant.userId : String(message.senderId)
messageObject.style.setProperty("--sender-color", colorForUser(String(participantUserId)))
messageObject.appendChild(((headerElement) => {
headerElement.classList.add("header")
@@ -278,6 +350,10 @@
headerElement.appendChild(((elem) => {
elem.classList.add("time")
elem.innerHTML = new Date(message.createdTimestamp).toUTCString()
if (message.isDeleted || message.type === "STATUS") {
elem.classList.add("with_deleted")
elem.appendChild(document.querySelector('.deleted_icon_svg').cloneNode(true))
}
return elem
})(document.createElement("div")))
@@ -384,4 +460,4 @@
makeHeader()
makeMain()
</script>
</body>
</body>

View File

@@ -161,7 +161,7 @@ class ModContext(
NativeConfig(
disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(),
disableMetrics = config.global.disableMetrics.get(),
composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true,
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true,
customEmojiFontPath = getCustomEmojiFontPath(this)
)
)

View File

@@ -210,7 +210,6 @@ class PurrfectSnap {
}
}
private var safeMode = false
private fun triggerMappingsGeneration() {
runCatching {
@@ -221,7 +220,7 @@ class PurrfectSnap {
}
val intent = Intent().apply {
setClassName(Constants.SE_PACKAGE_NAME, "${Constants.SE_PACKAGE_NAME}.ui.setup.SetupActivity")
setClassName(Constants.MODULE_PACKAGE_NAME, "${Constants.MODULE_PACKAGE_NAME}.ui.setup.SetupActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("requirements", 4) // Requirements.MAPPINGS = 4
}
@@ -233,24 +232,12 @@ class PurrfectSnap {
}
private fun onActivityCreate(activity: Activity) {
if (!appContext.native.verifyKey(BuildConfig.NATIVE_KEY)) {
safeMode = true
}
measureTimeMillis {
with(appContext) {
features.onActivityCreate(activity)
inAppOverlay.onActivityCreate(activity)
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) }
actionManager.onActivityCreate()
val isTestModeEnabled = appContext.bridgeClient.getDebugProp("test_mode", "false") == "true"
if (safeMode && !isTestModeEnabled) {
appContext.inAppOverlay.showStatusToast(
Icons.Outlined.Cancel,
"Failed to load security features! Snapchat may not work properly.",
durationMs = 3000
)
}
}
}.also { time ->
appContext.log.verbose("onActivityCreate took $time")
@@ -268,7 +255,6 @@ class PurrfectSnap {
}
val lateInit = appContext.native.initOnce {
verifyKey(BuildConfig.NATIVE_KEY)
nativeUnaryCallCallback = { request ->
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
request.buffer = buffer
@@ -453,10 +439,26 @@ class PurrfectSnap {
}
}
val stringResources = strings(androidx.compose.material3.R.string::class, androidx.compose.ui.R.string::class)
fun resolveComposeString(key: Int): String? {
val name = stringResources[key]?.replaceFirst("m3c_", "") ?: return null
return appContext.translation.getOrNull("material3_strings.${name}") ?: ""
}
fun resolveInvalidString(resources: Resources, key: Int): String? {
val type = runCatching { resources.getResourceTypeName(key) }.getOrNull() ?: return ""
return if (type == "string") null else ""
}
Resources::class.java.getMethod("getString", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
val key = param.arg<Int>(0)
val name = stringResources[key]?.replaceFirst("m3c_", "") ?: return@hook
param.setResult(appContext.translation.getOrNull("material3_strings.${name}") ?: "")
resolveComposeString(key)?.let { param.setResult(it); return@hook }
resolveInvalidString(param.thisObject() as Resources, key)?.let { param.setResult(it) }
}
Resources::class.java.getMethod("getText", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
val key = param.arg<Int>(0)
resolveComposeString(key)?.let { param.setResult(it); return@hook }
resolveInvalidString(param.thisObject() as Resources, key)?.let { param.setResult(it) }
}
}
}

View File

@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -36,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 androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -144,13 +147,20 @@ class BulkMessagingAction : AbstractAction() {
ViewAppearanceHelper.newAlertDialogBuilder(ctx)
.setTitle("...")
.setView(LinearLayout(ctx).apply {
val padding = (16 * ctx.resources.displayMetrics.density).toInt()
val spacing = (8 * ctx.resources.displayMetrics.density).toInt()
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(padding, padding, padding, padding)
addView(statusTextView.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
textAlignment = View.TEXT_ALIGNMENT_CENTER
setSingleLine(false)
setPadding(0, 0, 0, spacing)
})
addView(ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
addView(ProgressBar(ctx))
})
.setCancelable(false)
.show()
@@ -184,21 +194,71 @@ class BulkMessagingAction : AbstractAction() {
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(text = translation["confirmation_dialog.title"]) },
text = { Text(text = translation["confirmation_dialog.message"]) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = context.translation["button.positive"])
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text(text = context.translation["button.negative"])
Dialog(onDismissRequest = onCancel) {
val shape = RoundedCornerShape(22.dp)
Surface(
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 16.dp,
border = BorderStroke(1.dp, BulkMessagingPalette.glowStroke)
) {
Column(
modifier = Modifier
.background(BulkMessagingPalette.cardOverlay, shape)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = BulkMessagingPalette.faintSurface
) {
Icon(
Icons.Default.WarningAmber,
contentDescription = null,
tint = BulkMessagingPalette.textPrimary,
modifier = Modifier.padding(10.dp)
)
}
Text(
text = translation["confirmation_dialog.title"],
style = MaterialTheme.typography.titleLarge,
color = BulkMessagingPalette.textPrimary,
textAlign = TextAlign.Center
)
Text(
text = translation["confirmation_dialog.message"],
style = MaterialTheme.typography.bodyMedium,
color = BulkMessagingPalette.textSecondary,
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
) {
Button(
onClick = onCancel,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = BulkMessagingPalette.textPrimary
)
) {
Text(text = context.translation["button.negative"])
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = BulkMessagingPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = BulkMessagingPalette.textPrimary
)
) {
Text(text = context.translation["button.positive"])
}
}
}
}
)
}
}
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
@@ -728,7 +788,7 @@ class BulkMessagingAction : AbstractAction() {
ConversationType.FRIENDS_ONLY -> translation["no_friends_found"]
ConversationType.GROUPS_ONLY -> translation["no_groups_found"]
ConversationType.BOTH -> translation["no_friends_or_groups_found"]
}, fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}, fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = BulkMessagingPalette.textPrimary)
}
}
items(friends, key = { it.userId!! }) { friendInfo ->
@@ -1215,7 +1275,12 @@ class BulkMessagingAction : AbstractAction() {
) {
actionsList.forEach { (textBuilder, actionFunction) ->
DropdownMenuItem(
text = { Text(text = remember(selectedFriends.size, selectedGroups.size) { textBuilder() }) },
text = {
Text(
text = remember(selectedFriends.size, selectedGroups.size) { textBuilder() },
color = BulkMessagingPalette.textPrimary
)
},
onClick = {
actionsMenuExpanded = false
showConfirmationDialog = true

View File

@@ -1,12 +1,15 @@
package me.eternal.purrfectsnap.core.action.impl
import android.app.AlertDialog
import android.content.DialogInterface
import android.graphics.Color as AndroidColor
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -31,6 +34,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
@@ -45,13 +49,23 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorPickerController
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.common.database.impl.FriendFeedEntry
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.core.action.AbstractAction
@@ -60,14 +74,21 @@ import me.eternal.purrfectsnap.core.logger.CoreLogger
import me.eternal.purrfectsnap.core.messaging.ConversationExporter
import me.eternal.purrfectsnap.core.messaging.ExportFormat
import me.eternal.purrfectsnap.core.messaging.ExportParams
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import java.io.File
import kotlin.math.absoluteValue
private data class ExportColorParticipant(
val userId: String,
val displayName: String,
val username: String
)
class ExportChatMessages : AbstractAction() {
private val translation by lazy { context.translation.getCategory("chat_export") }
private val dialogLogs = mutableListOf<String>()
private var dialogTitle by mutableStateOf("")
private var dialogText by mutableStateOf("")
private var currentActionDialog: AlertDialog? = null
private val dialogBackground = Brush.verticalGradient(
listOf(
@@ -88,18 +109,59 @@ class ExportChatMessages : AbstractAction() {
)
)
private data class ExportTarget(
val outputFile: File,
val finalize: (File) -> String
)
private fun resolveExportTarget(fileName: String, mimeType: String): ExportTarget {
val configuredFolder = context.config.downloader.saveFolder.get()?.trim().orEmpty()
val defaultTarget = {
val publicFolder = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"PurrfectSnap"
).also { if (!it.exists()) it.mkdirs() }
val outputFile = publicFolder.resolve(fileName).also { if (it.exists()) it.delete() }
ExportTarget(outputFile) { file -> file.absolutePath }
}
if (configuredFolder.isBlank()) {
return defaultTarget()
}
val outputFolder = runCatching {
DocumentFile.fromTreeUri(context.androidContext, Uri.parse(configuredFolder))
}.getOrNull()
if (outputFolder == null || !outputFolder.canWrite()) {
return defaultTarget()
}
val tempFile = File(context.androidContext.cacheDir, fileName).also {
if (it.exists()) it.delete()
}
return ExportTarget(tempFile) { file ->
val outputFile = outputFolder.createFile(mimeType, fileName)
?: throw IllegalStateException("Failed to create export file")
context.androidContext.contentResolver.openOutputStream(outputFile.uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to write export file")
outputFile.uri.toString()
}
}
private fun logDialog(message: String) {
context.runOnUiThread {
if (dialogLogs.size > 10) dialogLogs.removeAt(0)
dialogLogs.add(message)
context.log.debug("dialog: $message", "ExportChatMessages")
currentActionDialog!!.setMessage(dialogLogs.joinToString("\n"))
dialogText = dialogLogs.joinToString("\n")
}
}
private fun setStatus(message: String) {
context.runOnUiThread {
currentActionDialog!!.setTitle(message)
dialogTitle = message
}
}
@@ -119,6 +181,10 @@ class ExportChatMessages : AbstractAction() {
var showConversationPicker by remember { mutableStateOf(false) }
var showFormatPicker by remember { mutableStateOf(false) }
var showMessageTypePicker by remember { mutableStateOf(false) }
val colorOverrides = remember { mutableStateMapOf<String, String>() }
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
var participants by remember { mutableStateOf<List<ExportColorParticipant>>(emptyList()) }
val allFriends by rememberAsyncMutableState(null) { context.database.getAllFriends().associateBy { it.userId!! } }
val myUserId = context.database.myUserId
val focusManager = LocalFocusManager.current
@@ -272,6 +338,66 @@ class ExportChatMessages : AbstractAction() {
accent = accent
)
SectionLabel("Participant colors")
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 260.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(18.dp))
.verticalScroll(rememberScrollState())
.padding(12.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (participants.isEmpty()) {
BasicText(
text = "Select conversations to customize participant colors.",
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp)
)
} else {
participants.forEach { participant ->
val colorHex = colorOverrides[participant.userId]
val color = colorHex?.let { Color(AndroidColor.parseColor(it)) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.05f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(14.dp))
.clickable {
colorPickerTarget = participant
colorPickerValue = color
}
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorSwatch(color = color)
Column(modifier = Modifier.weight(1f)) {
BasicText(
text = participant.displayName,
style = TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
BasicText(
text = participant.username,
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
BasicText(
text = colorHex ?: "Auto",
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 11.sp)
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
@@ -284,15 +410,17 @@ class ExportChatMessages : AbstractAction() {
PrimaryButton(
text = translation["dialog_positive_button"],
modifier = Modifier.weight(1f),
enabled = selectedFeedEntries.isNotEmpty(),
enabled = selectedFeedEntries.isNotEmpty() || feedEntries.isNotEmpty(),
onClick = {
val selection = if (selectedFeedEntries.isEmpty()) feedEntries else selectedFeedEntries
exportChatForConversations(
selectedFeedEntries,
selection,
ExportParams(
exportFormat = exportType,
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
amountOfMessages = amountOfMessages.takeIf { it != -1 },
downloadMedias = downloadMedias
downloadMedias = downloadMedias,
colorOverrides = colorOverrides.takeIf { it.isNotEmpty() }?.toMap()
)
)
}
@@ -307,6 +435,56 @@ class ExportChatMessages : AbstractAction() {
}
}
LaunchedEffect(selectedFeedEntries.toList(), allFriends) {
withContext(Dispatchers.IO) {
val selection = selectedFeedEntries.toList()
if (selection.isEmpty()) {
withContext(Dispatchers.Main) {
participants = emptyList()
}
return@withContext
}
val participantsMap = linkedMapOf<String, ExportColorParticipant>()
selection.forEach { entry ->
val userIds = context.database.getConversationParticipants(entry.key!!, useCache = false) ?: emptyList()
userIds.forEach { userId ->
if (participantsMap.containsKey(userId)) return@forEach
val friend = allFriends?.get(userId) ?: context.database.getFriendInfo(userId)
val displayName = friend?.displayName ?: friend?.mutableUsername ?: userId
val username = friend?.mutableUsername ?: userId
participantsMap[userId] = ExportColorParticipant(userId, displayName, username)
}
}
withContext(Dispatchers.Main) {
participants = participantsMap.values.toList()
}
}
}
colorPickerTarget?.let { target ->
Dialog(
onDismissRequest = { colorPickerTarget = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
ExportColorPickerDialog(
participant = target,
initialColor = colorPickerValue,
onSave = { color ->
if (color == null) {
colorOverrides.remove(target.userId)
} else {
colorOverrides[target.userId] = colorToHex(color)
}
colorPickerTarget = null
},
onClear = {
colorOverrides.remove(target.userId)
colorPickerTarget = null
}
)
}
}
if (showConversationPicker) {
Box(
modifier = Modifier
@@ -340,6 +518,31 @@ class ExportChatMessages : AbstractAction() {
}
)
}
if (feedEntries.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SecondaryButton(
text = t("text_field_selection_all"),
modifier = Modifier.weight(1f),
onClick = {
selectedFeedEntries.clear()
selectedFeedEntries.addAll(feedEntries)
}
)
SecondaryButton(
text = translation["dialog_negative_button"],
modifier = Modifier.weight(1f),
onClick = { selectedFeedEntries.clear() }
)
}
}
}
}
}
}
@@ -686,6 +889,190 @@ class ExportChatMessages : AbstractAction() {
}
}
@Composable
private fun ExportProgressDialog(
onCancel: () -> Unit
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxWidth()
.background(dialogBackground)
.padding(12.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.border(1.2.dp, accentGradient, RoundedCornerShape(26.dp)),
shape = RoundedCornerShape(26.dp),
tonalElevation = 0.dp,
color = Color.White.copy(alpha = 0.04f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(panelOverlay)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = dialogTitle,
style = MaterialTheme.typography.titleMedium.copy(
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 280.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(18.dp))
.verticalScroll(scrollState)
.padding(12.dp)
) {
BasicText(
text = dialogText,
style = TextStyle(color = Color(0xFFD9D3FF), fontSize = 12.sp)
)
}
SecondaryButton(
text = translation["dialog_negative_button"],
modifier = Modifier.fillMaxWidth(),
onClick = onCancel
)
}
}
}
}
@Composable
private fun ColorSwatch(color: Color?) {
Box(
modifier = Modifier
.size(26.dp)
.clip(RoundedCornerShape(8.dp))
.background(color ?: Color.White.copy(alpha = 0.1f))
.border(1.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
if (color == null) {
BasicText(
text = "A",
style = TextStyle(color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
)
}
}
}
private fun colorToHex(color: Color): String {
return String.format("#%06X", 0xFFFFFF and color.toArgb())
}
@Composable
private fun ExportColorPickerDialog(
participant: ExportColorParticipant,
initialColor: Color?,
onSave: (Color?) -> Unit,
onClear: () -> Unit
) {
var currentColor by remember { mutableStateOf(initialColor ?: Color.White) }
val controller = remember { ColorPickerController().apply { selectByColor(currentColor, false) } }
var colorHexValue by remember { mutableStateOf(colorToHex(currentColor).removePrefix("#")) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 16.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.6f),
Color(0xFF5FD8FF).copy(alpha = 0.5f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF1B1636))
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Color for ${participant.displayName}",
style = MaterialTheme.typography.titleMedium.copy(
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
)
TextField(
value = colorHexValue,
onValueChange = { value ->
colorHexValue = value
runCatching {
val parsed = Color(AndroidColor.parseColor("#$value"))
currentColor = parsed
controller.selectByColor(parsed, true)
}
},
label = { Text(text = "Hex Color") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFF8EF0F3),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
controller = controller,
onColorChanged = {
if (!it.fromUser) return@HsvColorPicker
currentColor = it.color
colorHexValue = colorToHex(it.color).removePrefix("#")
}
)
BrightnessSlider(
modifier = Modifier
.fillMaxWidth()
.height(30.dp),
controller = controller
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
SecondaryButton(
text = "Auto",
modifier = Modifier.weight(1f),
onClick = onClear
)
PrimaryButton(
text = "Save",
modifier = Modifier.weight(1f),
onClick = { onSave(currentColor) }
)
}
}
}
}
override fun run() {
context.coroutineScope.launch(Dispatchers.Main) {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
@@ -722,18 +1109,14 @@ class ExportChatMessages : AbstractAction() {
) {
dialogLogs.clear()
val jobs = mutableListOf<Job>()
dialogTitle = translation["exporting_chats"]
dialogText = ""
currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(translation["exporting_chats"])
.setCancelable(false)
.setMessage("")
.create()
val conversationSize = translation.format("processing_chats", "amount" to conversations.size.toString())
logDialog(conversationSize)
context.coroutineScope.launch {
val exportJob = context.coroutineScope.launch {
conversations.forEach { conversation ->
launch {
runCatching {
@@ -747,17 +1130,33 @@ class ExportChatMessages : AbstractAction() {
}
jobs.joinAll()
logDialog(translation["finished"])
}.also {
currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, translation["dialog_negative_button"]) { dialog, _ ->
it.cancel()
jobs.forEach { it.cancel() }
dialog.dismiss()
}
}
currentActionDialog!!.also {
it.setCanceledOnTouchOutside(false)
}.show()
currentActionDialog = createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
ExportProgressDialog {
exportJob.cancel()
jobs.forEach { it.cancel() }
alertDialog.dismiss()
}
}.apply {
setCanceledOnTouchOutside(false)
show()
}
}
private fun fetchLoggerMessages(conversationId: String): List<LoggedMessage> {
return runCatching {
val loggerWrapper = LoggerWrapper(context.androidContext)
val messages = mutableListOf<LoggedMessage>()
var fromTimestamp = Long.MAX_VALUE
while (true) {
val batch = loggerWrapper.fetchMessages(conversationId, fromTimestamp, 500, reverseOrder = true)
if (batch.isEmpty()) break
messages.addAll(batch)
fromTimestamp = batch.last().sendTimestamp
}
messages
}.getOrDefault(emptyList())
}
private suspend fun exportFullConversation(
@@ -771,35 +1170,59 @@ class ExportChatMessages : AbstractAction() {
context.database.getFriendInfo(it)
}?.associateBy { it.userId!! } ?: emptyMap()
val loggerMessages = fetchLoggerMessages(conversationId)
val participantMap = conversationParticipants.toMutableMap().apply {
loggerMessages.forEach { message ->
if (containsKey(message.userId)) return@forEach
this[message.userId] = FriendInfo(
userId = message.userId,
displayName = message.username,
username = message.username,
usernameForSorting = message.username
)
}
}
val conversationName = feedEntry.feedDisplayName ?: conversationParticipants.values.take(3).joinToString("_") { it.mutableUsername ?: "" }
val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "PurrfectSnap").also { if (!it.exists()) it.mkdirs() }
val outputFile = publicFolder.resolve("conversation_${conversationName}_${System.currentTimeMillis()}.${exportParams.exportFormat.extension}")
val outputName = "conversation_${conversationName}_${System.currentTimeMillis()}.${exportParams.exportFormat.extension}"
val mimeType = when (exportParams.exportFormat) {
ExportFormat.JSON -> "application/json"
ExportFormat.TEXT -> "text/plain"
ExportFormat.HTML -> "text/html"
}
val outputTarget = resolveExportTarget(outputName, mimeType)
val outputFile = outputTarget.outputFile
logDialog(translation.format("exporting_message", "conversation" to conversationName))
val conversationExporter = ConversationExporter(
context = context,
friendFeedEntry = feedEntry,
conversationParticipants = conversationParticipants,
conversationParticipants = participantMap,
exportParams = exportParams,
cacheFolder = publicFolder.resolve("cache").also { if (!it.exists()) it.mkdirs() },
cacheFolder = context.androidContext.cacheDir.resolve("chat_export").also { if (!it.exists()) it.mkdirs() },
outputFile = outputFile,
).apply { init(); printLog = {
logDialog(it.toString())
} }
var foundMessageCount = 0
val exportedOrderKeys = mutableSetOf<Long>()
var lastMessageId = fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also {
conversationExporter.readMessage(it)
var lastMessageId: Long? = null
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
conversationExporter.readMessage(message)
foundMessageCount++
}?.messageDescriptor?.messageId ?: run {
logDialog(translation["no_messages_found"])
return
message.orderKey?.let { exportedOrderKeys.add(it) }
lastMessageId = message.messageDescriptor?.messageId
}
while (true) {
if (lastMessageId == null) {
logDialog(translation["no_messages_found"])
}
while (lastMessageId != null) {
val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList()
if (fetchedMessages.isEmpty()) break
@@ -813,28 +1236,49 @@ class ExportChatMessages : AbstractAction() {
}
}
foundMessageCount += fetchedMessages.size
val remainingLimit = exportParams.amountOfMessages?.let { it - foundMessageCount } ?: Int.MAX_VALUE
if (remainingLimit <= 0) break
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) {
fetchedMessages.reversed().subList(0, exportParams.amountOfMessages - (foundMessageCount - fetchedMessages.size)).forEach { message ->
conversationExporter.readMessage(message)
}
break
val messagesToWrite = fetchedMessages.reversed().let { messages ->
if (messages.size <= remainingLimit) messages else messages.subList(0, remainingLimit)
}
fetchedMessages.reversed().forEach { message ->
messagesToWrite.forEach { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
}
setStatus("Exporting (found ${foundMessageCount})")
}
if (loggerMessages.isNotEmpty() && (exportParams.amountOfMessages == null || foundMessageCount < exportParams.amountOfMessages)) {
val parsedLoggerMessages = loggerMessages.mapNotNull { conversationExporter.parseLoggedMessage(it) }
for (loggedMessage in parsedLoggerMessages.asReversed()) {
if (exportedOrderKeys.contains(loggedMessage.orderKey)) continue
val filter = exportParams.messageTypeFilter
if (filter != null && !filter.contains(loggedMessage.contentType)) continue
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) break
conversationExporter.readLoggedMessage(loggedMessage)
foundMessageCount++
}
}
if (exportParams.exportFormat == ExportFormat.HTML) conversationExporter.awaitDownload()
conversationExporter.close()
logDialog(translation["writing_output"])
dialogLogs.clear()
val exportedPath = runCatching { outputTarget.finalize(outputFile) }.getOrElse { error ->
logDialog("Failed to write export output")
logDialog(error.toString())
context.log.error("Failed to finalize chat export", error)
return
}
if (outputFile.parentFile == context.androidContext.cacheDir) {
outputFile.delete()
}
logDialog("\n" + translation.format("exported_to",
"path" to outputFile.absolutePath.toString()
"path" to exportedPath
) + "\n")
}
}

View File

@@ -2,7 +2,9 @@ package me.eternal.purrfectsnap.core.action.impl
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteDatabase.OpenParams
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -100,6 +102,49 @@ class ExportMemories : AbstractAction() {
get() = storyTitle.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim().replace(Regex("\\s+"), "_")
}
private data class ExportTarget(
val outputFile: File,
val finalize: (File) -> String
)
private fun resolveExportTarget(fileName: String, mimeType: String): ExportTarget {
val configuredFolder = context.config.downloader.saveFolder.get()?.trim().orEmpty()
val defaultTarget = {
val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val outputDir = documentsDir.takeIf { it.exists() || it.mkdirs() }
?: context.androidContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.androidContext.filesDir
val outputFile = File(outputDir, fileName).also {
if (it.exists()) it.delete()
}
ExportTarget(outputFile) { file -> file.absolutePath }
}
if (configuredFolder.isBlank()) {
return defaultTarget()
}
val outputFolder = runCatching {
DocumentFile.fromTreeUri(context.androidContext, Uri.parse(configuredFolder))
}.getOrNull()
if (outputFolder == null || !outputFolder.canWrite()) {
return defaultTarget()
}
val tempFile = File(context.androidContext.cacheDir, fileName).also {
if (it.exists()) it.delete()
}
return ExportTarget(tempFile) { file ->
val outputFile = outputFolder.createFile(mimeType, fileName)
?: throw IllegalStateException("Failed to create export file")
context.androidContext.contentResolver.openOutputStream(outputFile.uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to write export file")
outputFile.uri.toString()
}
}
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalEncodingApi::class)
private suspend fun exportMemories(
scope: CoroutineScope = context.coroutineScope,
@@ -111,9 +156,11 @@ class ExportMemories : AbstractAction() {
) {
val downloadContext = Dispatchers.IO.limitedParallelism(10)
val writeToZipContext = Dispatchers.IO.limitedParallelism(1)
val outputZip = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "memories_" + System.currentTimeMillis() + ".zip").also {
if (it.exists()) it.delete()
}
val outputTarget = resolveExportTarget(
"memories_${System.currentTimeMillis()}.zip",
"application/zip"
)
val outputZip = outputTarget.outputFile
val okHttpClient = OkHttpClient.Builder().build()
val outputZipFile = withContext(Dispatchers.IO) {
ZipOutputStream(FileOutputStream(outputZip)).apply {
@@ -249,7 +296,16 @@ class ExportMemories : AbstractAction() {
withContext(Dispatchers.IO) {
outputZipFile.close()
}
context.longToast("Exported to ${outputZip.absolutePath}")
val exportedPath = runCatching { outputTarget.finalize(outputZip) }
.getOrElse { error ->
context.log.error("Failed to finalize memories export", error)
context.longToast("Failed to export memories")
return
}
if (outputZip.parentFile == context.androidContext.cacheDir) {
outputZip.delete()
}
context.longToast("Exported to $exportedPath")
}
@OptIn(ExperimentalMaterial3Api::class)

View File

@@ -1,6 +1,8 @@
package me.eternal.purrfectsnap.core.action.impl
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -12,9 +14,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
@@ -26,25 +30,48 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.action.AbstractAction
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver
import me.eternal.purrfectsnap.core.features.impl.experiments.AddFriendSourceSpoof
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.wrapper.impl.Snapchatter
import kotlin.random.Random
class ManageFriendList : AbstractAction() {
companion object {
private var openSuggestedOnLaunch = false
@Synchronized
fun requestOpenSuggestedOnLaunch() {
openSuggestedOnLaunch = true
}
@Synchronized
private fun consumeOpenSuggestedOnLaunch(): Boolean {
val shouldOpen = openSuggestedOnLaunch
openSuggestedOnLaunch = false
return shouldOpen
}
}
private val translation by lazy { context.translation.getCategory("friend_list") }
private val dialogBackground = Brush.verticalGradient(
listOf(
@@ -216,6 +243,22 @@ class ManageFriendList : AbstractAction() {
}
}
private fun loadSuggestedFriends(
coroutineScope: CoroutineScope,
onLoaded: (List<String>) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
val blacklist = getUserIdBlacklist()
val suggestedFriends = context.database.getAllFriends()
.filter { it.userId !in blacklist && it.friendLinkType == FriendLinkType.SUGGESTED.value }
.sortedByDescending { it.addedTimestamp }
.mapNotNull { it.userId }
withContext(Dispatchers.Main) {
onLoaded(suggestedFriends)
}
}
}
override fun onActivityCreate() {
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode == pendingPickerAction?.first) {
@@ -254,12 +297,27 @@ class ManageFriendList : AbstractAction() {
}
private val userIdToSnapchatter = mutableMapOf<String, Snapchatter>()
private fun getUserIdBlacklist() = arrayOf(
context.database.myUserId,
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781",
"84ee8839-3911-492d-8b94-72dd80f3713a",
)
@Composable
private fun ManagerDialog() {
val pendingFriendRequests = remember { mutableStateMapOf<String, Job>() }
var fetchedFriends by remember { mutableStateOf<List<String>?>(null) }
val coroutineScope = rememberCoroutineScope()
val openSuggestedOnLaunch = remember { consumeOpenSuggestedOnLaunch() }
val bitmojiCache = remember { me.eternal.purrfectsnap.core.util.EvictingMap<String, Bitmap>(50) }
val noBitmojiBitmap = remember { BitmapFactory.decodeResource(context.resources, android.R.drawable.ic_menu_report_image).asImageBitmap() }
LaunchedEffect(openSuggestedOnLaunch) {
if (openSuggestedOnLaunch) {
loadSuggestedFriends(coroutineScope) { fetchedFriends = it }
}
}
Box(
modifier = Modifier
@@ -387,8 +445,30 @@ class ManageFriendList : AbstractAction() {
)
}
}
PrimaryButton(
text = "Load Suggested Friends",
modifier = Modifier.fillMaxWidth()
) {
loadSuggestedFriends(coroutineScope) { fetchedFriends = it }
}
}
} else {
var searchQuery by remember { mutableStateOf("") }
val filteredFriends = remember(fetchedFriends, searchQuery) {
val friends = fetchedFriends ?: emptyList()
if (searchQuery.isBlank()) {
friends.sortedByDescending { context.database.getFriendInfo(it)?.addedTimestamp ?: 0L }
} else {
friends.filter { userId ->
val friendInfo = context.database.getFriendInfo(userId)
friendInfo?.mutableUsername?.contains(searchQuery, ignoreCase = true) == true ||
friendInfo?.displayName?.contains(searchQuery, ignoreCase = true) == true ||
userId.contains(searchQuery, ignoreCase = true)
}.sortedByDescending { context.database.getFriendInfo(it)?.addedTimestamp ?: 0L }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -411,9 +491,10 @@ class ManageFriendList : AbstractAction() {
.clickable { fetchedFriends = null },
contentAlignment = Alignment.Center
) {
Image(
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = context.translation["common.back"],
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
@@ -427,16 +508,45 @@ class ManageFriendList : AbstractAction() {
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Text(
text = translation.get("export_description"),
color = Color(0xFFD9D3FF),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.size(46.dp))
}
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 14.sp),
cursorBrush = SolidColor(Color(0xFF8EF0F3)),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = Color(0xFFB1B4D7),
modifier = Modifier.size(18.dp)
)
Box(Modifier.weight(1f)) {
if (searchQuery.isEmpty()) {
BasicText(
"Search...",
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFB1B4D7), fontSize = 14.sp)
)
}
innerTextField()
}
}
}
)
Surface(
modifier = Modifier
@@ -453,7 +563,7 @@ class ManageFriendList : AbstractAction() {
.padding(12.dp)
) {
item {
if (fetchedFriends?.isEmpty() == true) {
if (filteredFriends.isEmpty()) {
BasicText(
context.translation["common.no_friends_found"],
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFA8B5D1), fontSize = 13.sp),
@@ -461,47 +571,70 @@ class ManageFriendList : AbstractAction() {
)
}
}
items(fetchedFriends ?: emptyList()) { userId ->
fun fetchLocalLinkType(): FriendLinkType? {
return context.database.getFriendInfo(userId)?.friendLinkType?.let { FriendLinkType.fromValue(it) }
items(filteredFriends) { userId ->
val friendInfo = remember(userId) { context.database.getFriendInfo(userId) }
val linkType = remember(friendInfo) {
friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
}
fun isActuallyAdded(): Boolean {
val friendInfo = context.database.getFriendInfo(userId)
return friendInfo != null &&
(friendInfo.friendLinkType == FriendLinkType.MUTUAL.value ||
friendInfo.friendLinkType == FriendLinkType.OUTGOING.value) &&
friendInfo.addedTimestamp > 0L
val isActuallyAdded = remember(friendInfo, linkType) {
friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING)
}
var friendSnapchatter by remember(userId) { mutableStateOf<Snapchatter?>(null) }
var failedToFetch by remember(userId) { mutableStateOf(false) }
var friendLinkType by remember(userId) { mutableStateOf(fetchLocalLinkType()) }
var actuallyAdded by remember(userId) { mutableStateOf(isActuallyAdded()) }
var friendLinkType by remember(userId) { mutableStateOf(linkType) }
var actuallyAdded by remember(userId) { mutableStateOf(isActuallyAdded) }
var bitmojiBitmap by remember(userId, friendInfo?.bitmojiAvatarId) {
mutableStateOf(friendInfo?.bitmojiAvatarId?.let { bitmojiCache[it] })
}
LaunchedEffect(userId) {
launch(Dispatchers.IO) {
friendSnapchatter = userIdToSnapchatter.getOrPut(userId) {
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull() ?: run {
failedToFetch = true
return@launch
if (friendSnapchatter == null && !userIdToSnapchatter.containsKey(userId)) {
withContext(Dispatchers.IO) {
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull()?.let {
userIdToSnapchatter[userId] = it
friendSnapchatter = it
}
}
} else {
friendSnapchatter = userIdToSnapchatter[userId]
}
// Polling loop to keep status in sync (like FriendList.kt)
while (true) {
delay(2000)
val newLinkType = fetchLocalLinkType()
val newLinkType = friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
if (newLinkType != friendLinkType) {
friendLinkType = newLinkType
}
val newActuallyAdded = isActuallyAdded()
val newActuallyAdded = friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING)
if (newActuallyAdded != actuallyAdded) {
actuallyAdded = newActuallyAdded
}
}
}
LaunchedEffect(userId, friendInfo?.bitmojiAvatarId, friendInfo?.bitmojiSelfieId) {
if (bitmojiBitmap != null || friendInfo?.bitmojiAvatarId == null || friendInfo?.bitmojiSelfieId == null) return@LaunchedEffect
withContext(Dispatchers.IO) {
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(
friendInfo.bitmojiSelfieId,
friendInfo.bitmojiAvatarId,
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
) ?: return@withContext
runCatching {
RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, length ->
val avatarId = friendInfo.bitmojiAvatarId ?: return@downloadMedia
bitmojiCache[avatarId] = BitmapFactory.decodeStream(inputStream).also {
bitmojiBitmap = it
}
}
}
}
}
Row(
modifier = Modifier
@@ -511,94 +644,84 @@ class ManageFriendList : AbstractAction() {
.background(Color.White.copy(alpha = 0.05f))
.border(1.dp, accentGradient, RoundedCornerShape(14.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
bitmap = remember(bitmojiBitmap) { bitmojiBitmap?.asImageBitmap() ?: noBitmojiBitmap },
contentDescription = null,
modifier = Modifier.size(35.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
BasicText(
friendSnapchatter?.let { snapchatter ->
snapchatter.displayName?.let { "$it (${snapchatter.username}) " } ?: snapchatter.username ?: context.translation["common.unknown"]
} ?: userId,
style = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(
text = friendSnapchatter?.let { snapchatter ->
snapchatter.displayName?.let { "$it (${snapchatter.username})" }
?: snapchatter.username
?: context.translation["common.unknown"]
} ?: context.translation["common.unknown"],
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
BasicText(
userId,
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp)
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
friendLinkType?.let { type ->
StatusPill(
text = type.name.lowercase().replaceFirstChar { it.uppercase() },
color = if (type == FriendLinkType.MUTUAL) Color(0xFF8EF0F3) else Color(0xFFD9D3FF)
)
}
if (failedToFetch) {
StatusPill(
text = translation.get("failed_to_fetch") ?: "Fetch failed",
color = Color(0xFFF4B4B4)
)
}
}
}
if (friendSnapchatter != null) {
val isPending = pendingFriendRequests.containsKey(userId) && pendingFriendRequests[userId]?.isActive != false
val isFollowing = friendLinkType == FriendLinkType.FOLLOWING
if (friendSnapchatter != null) {
val isPending = pendingFriendRequests.containsKey(userId) && pendingFriendRequests[userId]?.isActive != false
val isFollowing = friendLinkType == FriendLinkType.FOLLOWING
PrimaryButton(
text = when {
isFollowing -> "Following"
actuallyAdded -> context.translation["common.added"]
isPending -> translation.get("adding") ?: "Adding..."
else -> translation.get("add")
},
modifier = Modifier.widthIn(min = 110.dp),
enabled = !actuallyAdded && !isPending && !isFollowing
) {
if (actuallyAdded || isPending || isFollowing) return@PrimaryButton
val job = coroutineScope.launch {
try {
PrimaryButton(
text = when {
isFollowing -> "Following"
actuallyAdded -> context.translation["common.added"]
isPending -> translation.get("adding") ?: "Adding..."
else -> translation.get("add")
},
modifier = Modifier.widthIn(min = 110.dp),
enabled = !actuallyAdded && !isPending && !isFollowing
) {
if (actuallyAdded || isPending || isFollowing) return@PrimaryButton
val prevLinkType = friendLinkType
addFriend(userId)
delay(300)
actuallyAdded = true
withTimeout(3000) {
var attempts = 0
while (attempts < 12) {
val currentLinkType = fetchLocalLinkType()
if (currentLinkType == FriendLinkType.MUTUAL ||
currentLinkType == FriendLinkType.FOLLOWING ||
currentLinkType == FriendLinkType.OUTGOING) {
friendLinkType = currentLinkType
actuallyAdded = true
break
val job = coroutineScope.launch {
withTimeout(10000) {
while (friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }?.value == prevLinkType?.value) {
delay(500)
}
attempts++
delay(250)
}
}.apply {
invokeOnCompletion {
pendingFriendRequests.remove(userId)
friendLinkType = friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
actuallyAdded = isActuallyAdded || (friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING))
}
}
} catch (e: Exception) {
context.log.error("Failed to add friend or verify status: ${e.message}")
actuallyAdded = true
pendingFriendRequests[userId] = job
}
}.apply {
invokeOnCompletion {
pendingFriendRequests.remove(userId)
friendLinkType = fetchLocalLinkType()
actuallyAdded = isActuallyAdded() || actuallyAdded
if (isPending) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color(0xFF8EF0F3)
)
}
}
pendingFriendRequests[userId] = job
}
if (isPending) {
Spacer(modifier = Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color(0xFF8EF0F3)
)
}
}
}

View File

@@ -84,14 +84,14 @@ class BridgeClient(
//ensure the remote process is running
runCatching {
startActivity(Intent()
.setClassName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.ForceStartActivity")
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.ForceStartActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
)
}
runCatching {
val intent = Intent()
.setClassName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
runCatching {
if (this@BridgeClient::service.isInitialized) {
unbindService(this@BridgeClient)
@@ -285,3 +285,4 @@ class BridgeClient(
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
}

View File

@@ -1,5 +1,7 @@
package me.eternal.purrfectsnap.core.data
import me.eternal.purrfectsnap.core.util.ClassDetector
class SnapClassCache (
private val classLoader: ClassLoader
) {
@@ -19,10 +21,50 @@ class SnapClassCache (
val conversation by lazy { findClass("com.snapchat.client.messaging.Conversation") }
val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") }
val nativeBridge by lazy { runCatching { findClass("com.snapchat.client.valdi.NativeBridge") }.getOrNull() ?: findClass("com.snapchat.client.composer.NativeBridge") }
val composerView by lazy { runCatching { findClass("com.snap.composer.views.ComposerView") }.getOrNull() }
val composerAction by lazy { runCatching { findClass("com.snap.composer.actions.ComposerAction") }.getOrNull() }
val composerFunctionActionAdapter by lazy { runCatching { findClass("com.snap.composer.callable.ComposerFunctionActionAdapter") }.getOrNull() }
val valdiView by lazy { runCatching { findClass("com.snap.valdi.views.ValdiView") }.getOrNull() ?: runCatching { findClass("com.snap.composer.views.ComposerView") }.getOrNull() }
val valdiFunction by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.callable.ValdiFunction",
"com.snap.composer.callable.ComposerFunction"
),
methodSignature = { clazz ->
clazz.isInterface && clazz.methods.any {
it.name == "perform" && it.parameterTypes.size == 1 &&
it.returnType == Boolean::class.javaPrimitiveType
}
}
)
}
val valdiMarshaller by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.utils.ValdiMarshaller",
"com.snap.composer.utils.ComposerMarshaller"
),
methodSignature = { clazz ->
!clazz.isInterface && clazz.methods.any { it.name == "getUntyped" }
}
)
}
val valdiFunctionActionAdapter by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.callable.ValdiFunctionActionAdapter",
"com.snap.composer.callable.ComposerFunctionActionAdapter"
),
methodSignature = { clazz ->
!clazz.isInterface && clazz.interfaces.isNotEmpty() &&
clazz.methods.any { it.name == "perform" && it.parameterTypes.size == 1 }
}
)
}
private fun findClass(className: String): Class<*> {
return try {
classLoader.loadClass(className)

View File

@@ -109,6 +109,7 @@ class FeatureManager(
HideStreakRestore(),
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),
@@ -135,7 +136,7 @@ class FeatureManager(
HideActiveMusic(),
AutoOpenSnaps(),
CustomStreaksExpirationFormat(),
ComposerHooks(),
ValdiHooks(),
DisableCustomTabs(),
BestFriendPinning(),
ContextMenuFix(),

View File

@@ -82,9 +82,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
val downloadLogging by context.config.downloader.logging
if (downloadLogging.contains("started")) {
context.shortToast(translations["download_started_toast"])
}
val outputPath = createNewFilePath(
context.config,

View File

@@ -1,42 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.Button
import android.widget.RelativeLayout
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
@SuppressLint("SetTextI18n")
@@ -51,153 +22,30 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe
event.parent.addView(ImageButton(event.parent.context).apply {
val label = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
val density = resources.displayMetrics.density
val sizePx = (44f * density).toInt()
contentDescription = label
setImageResource(android.R.drawable.stat_sys_download)
setColorFilter(Color.WHITE)
scaleType = ImageView.ScaleType.CENTER_INSIDE
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#332A2452"))
setStroke(3, Color.parseColor("#66AFA3FF"))
}
setPadding(0, 0, 0, 0)
layoutParams = RelativeLayout.LayoutParams(
sizePx,
sizePx
).apply {
event.parent.addView(Button(event.parent.context).apply {
text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
}
setOnClickListener {
val activity = this@ProfilePictureDownloader.context.mainActivity ?: return@setOnClickListener
val translation = this@ProfilePictureDownloader.context.translation
val options = buildList {
backgroundUrl?.let { add("background_option" to it) }
avatarUrl?.let { add("avatar_option" to it) }
}
createComposeAlertDialog(activity) { alertDialog ->
PurrfectOverlayTheme {
val dialogTitle = translation["profile_picture_downloader.title"]
?: "Profile Picture Downloader"
val subtitle = friendUsername ?: translation["profile_picture_downloader.subtitle"]
?: "Choose which image to download"
val border = remember {
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
)
ViewAppearanceHelper.newAlertDialogBuilder(
this@ProfilePictureDownloader.context.mainActivity!!
).apply {
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
val choices = mutableMapOf<String, String>()
backgroundUrl?.let { choices["background_option"] = it }
avatarUrl?.let { choices["avatar_option"] = it }
setItems(choices.keys.map {
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
}.toTypedArray()) { _, which ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
choices.values.elementAt(which),
friendUsername!!
)
}
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
shape = shape,
color = ComposeColor.Transparent,
tonalElevation = 0.dp,
shadowElevation = 18.dp,
border = BorderStroke(1.dp, border)
) {
Column(
modifier = Modifier
.background(PurrfectOverlayPalette.cardOverlay, shape)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(14.dp),
color = ComposeColor.White.copy(alpha = 0.08f)
) {
Icon(
Icons.Outlined.Download,
contentDescription = null,
tint = ComposeColor.White,
modifier = Modifier.padding(8.dp)
)
}
Column {
Text(
text = dialogTitle,
color = ComposeColor.White,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Text(
text = subtitle,
color = PurrfectOverlayPalette.textSecondary,
fontSize = 12.sp
)
}
}
if (options.isEmpty()) {
Text(
text = translation["profile_picture_downloader.no_images"]
?: "No profile images found.",
color = ComposeColor.White.copy(alpha = 0.8f),
fontSize = 13.sp
)
} else {
options.forEach { (key, url) ->
val labelText = translation["profile_picture_downloader.$key"]
val icon = if (key == "background_option") {
Icons.Outlined.Image
} else {
Icons.Outlined.AccountCircle
}
Surface(
onClick = {
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
url,
friendUsername!!
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
alertDialog.dismiss()
},
shape = RoundedCornerShape(16.dp),
color = ComposeColor.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, ComposeColor.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = ComposeColor.White.copy(alpha = 0.08f)
) {
Icon(
icon,
contentDescription = null,
tint = ComposeColor.White,
modifier = Modifier.padding(8.dp)
)
}
Text(
text = labelText ?: key,
color = ComposeColor.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
}
}
}
}
}
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
}
}.show()
@@ -220,4 +68,4 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
}
}
}
}

View File

@@ -56,7 +56,7 @@ class AppLock : Feature("AppLock") {
private fun requestUnlock() {
isUnlockRequested = true
context.mainActivity!!.startActivityForResult(Intent().apply {
component = ComponentName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BiometricPromptActivity")
component = ComponentName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BiometricPromptActivity")
}, requestCode)
}
@@ -151,4 +151,4 @@ class AppLock : Feature("AppLock") {
lock()
}
}
}
}

View File

@@ -21,7 +21,7 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
private fun generateAndroidId(): String {
if (spoofedAndroidId != null) return spoofedAndroidId!!
val customId = context.config.experimental.spoof.customAndroidId.getNullable()
val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable()
if (!customId.isNullOrEmpty()) {
spoofedAndroidId = customId.lowercase()
if (!hasLoggedId) {
@@ -242,7 +242,7 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName
val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag
val forceWifiTransportFlag by context.config.experimental.spoof.forceWifiTransportFlag
val spoofAndroidId by context.config.experimental.spoof.spoofAndroidId
val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId
if(overridePlayStoreInstallerPackageName) {
hookInstallerPackageName()

View File

@@ -188,7 +188,7 @@ class MediaFilePicker : Feature("Media File Picker") {
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
if (isAudio || context.config.messaging.galleryMediaSendOverride.getNullable() == null) {
if (isAudio || context.config.messaging.galleryMediaSendOverride.mode.getNullable() == null) {
startConversion(isAudio)
return@subscribe
}

View File

@@ -5,8 +5,8 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.getComposerContext
import me.eternal.purrfectsnap.core.ui.getComposerViewNode
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.ui.getValdiViewNode
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
@@ -35,23 +35,23 @@ class SnapScoreChanges: Feature("Snap Score Changes") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.viewClassName.endsWith("UnifiedProfileFlatlandProfileViewTopViewFrameLayout")) {
val composerView = (event.view as ViewGroup).getChildAt(0) ?: return@subscribe
val composerContext = composerView.getComposerContext() ?: return@subscribe
val composerContext = composerView.getValdiContext() ?: return@subscribe
lastViewedUserId = composerContext.viewModel?.getObjectField("_userId")?.toString()
}
if (event.viewClassName.endsWith("ProfileFlatlandFriendSnapScoreIdentityPillDialogView")) {
event.view.post {
event.view.getComposerContext()!!.enqueueNextRenderCallback {
val composerViewNode = event.view.getComposerViewNode() ?: return@enqueueNextRenderCallback
event.view.getValdiContext()!!.enqueueNextRenderCallback {
val composerViewNode = event.view.getValdiViewNode() ?: return@enqueueNextRenderCallback
val surface = composerViewNode.getChildren().getOrNull(1) ?: return@enqueueNextRenderCallback
val snapTextView = surface.getChildren().lastOrNull {
it.getClassName() == "com.snap.composer.views.ComposerSnapTextView"
it.getClassName().endsWith("SnapTextView")
} ?: return@enqueueNextRenderCallback
val currentFriendScore = scores[lastViewedUserId] ?: (event.view.getComposerContext()?.viewModel?.getObjectField("_friendSnapScore") as? Double)?.toLong() ?: return@enqueueNextRenderCallback
val currentFriendScore = scores[lastViewedUserId] ?: (event.view.getValdiContext()?.viewModel?.getObjectField("_friendSnapScore") as? Double)?.toLong() ?: return@enqueueNextRenderCallback
val oldSnapScore = context.bridgeClient.getTracker().updateFriendScore(
lastViewedUserId ?: return@enqueueNextRenderCallback,
@@ -68,4 +68,4 @@ class SnapScoreChanges: Feature("Snap Score Changes") {
}
}
}
}
}

View File

@@ -0,0 +1,238 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.common.bridge.toWrapper
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiFunction
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiMarshaller
import me.eternal.purrfectsnap.nativelib.NativeLib
import kotlin.math.absoluteValue
import kotlin.random.Random
class ValdiHooks: Feature("ValdiHooks") {
private val config by lazy { context.config.experimental.nativeHooks.valdiHooks }
private val getImportsFunctionName = Random.nextLong().absoluteValue.toString(16)
private var evalFunction: ValdiFunction? = null
private val valdiConsole by lazy {
createComposeAlertDialog(context.mainActivity!!) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
var result by remember { mutableStateOf("") }
var codeContent by remember { mutableStateOf("1 + 2") }
Text("Valdi Console", fontSize = 18.sp, fontWeight = FontWeight.Bold)
TextField(
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle.Default.copy(fontSize = 12.sp),
value = codeContent,
placeholder = { Text("Enter your JS code here:") },
onValueChange = {
codeContent = it
}
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
context.log.verbose("input: $codeContent", "ValdiConsole")
result = "Running..."
context.coroutineScope.launch {
ValdiMarshaller.create()?.use { valdiMarshaller ->
valdiMarshaller.pushUntyped(codeContent)
valdiMarshaller.pushUntyped(newValdiFunction {
if (getSize() < 1) return@newValdiFunction false
val output = getUntyped(0)
context.log.verbose("eval: $output", "ValdiConsole")
result = if (output is Exception) {
"${output.javaClass.simpleName}: ${output.message}"
} else {
output?.toString() ?: "undefined"
}
true
})
evalFunction?.perform(valdiMarshaller)
} ?: run {
result = "Failed to create ValdiMarshaller"
}
}
}
) {
Text("Run")
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Text(result)
}
}
}
}
private fun newValdiFunction(block: ValdiMarshaller.() -> Boolean): Any? {
val functionClass = PurrfectSnap.classCache.valdiFunction ?: return null
return java.lang.reflect.Proxy.newProxyInstance(
functionClass.classLoader,
arrayOf(functionClass)
) { _, method, args ->
if (method.name != "perform") return@newProxyInstance null
block(ValdiMarshaller(args?.get(0) ?: return@newProxyInstance false))
}
}
@Suppress("UNCHECKED_CAST")
override fun init() {
if (config.globalState != true) return
if (PurrfectSnap.classCache.valdiFunction == null) {
context.log.warn("ComposerFunction/ValdiFunction class not found, ValdiHooks feature disabled")
return
}
val importedFunctions = mutableMapOf<String, Any?>()
fun valdiFunction(name: String, block: ValdiMarshaller.() -> Unit) {
val function = newValdiFunction {
block(this)
true
}
if (function != null) {
importedFunctions[name] = function
}
}
valdiFunction("getConfig") {
pushUntyped(mapOf<String, Any>(
"operaDownloadButton" to context.config.downloader.operaDownloadButton.get(),
"bypassCameraRollLimit" to config.bypassCameraRollLimit.get(),
"showFirstCreatedUsername" to config.showFirstCreatedUsername.get(),
"valdiLogs" to config.valdiLogs.get(),
"customSelfDestructSnapDelay" to config.customSelfDestructSnapDelay.get(),
))
}
valdiFunction("showToast") {
if (getSize() < 1) return@valdiFunction
context.shortToast(getUntyped(0) as? String ?: return@valdiFunction)
}
valdiFunction("downloadLastOperaMedia") {
context.feature(MediaDownloader::class).downloadLastOperaMediaAsync(getUntyped(0) == true)
}
valdiFunction("getFriendOriginalUsername") {
if (getSize() < 1) return@valdiFunction
val username = getUntyped(0) as? String ?: return@valdiFunction
runCatching {
pushUntyped(context.database.getFriendOriginalUsername(username))
}.onFailure {
pushUntyped(null)
}
}
valdiFunction("log") {
if (getSize() < 2) return@valdiFunction
val logLevel = getUntyped(0) as? String ?: return@valdiFunction
val message = getUntyped(1) as? String ?: return@valdiFunction
val tag = "ValdiLogs"
when (logLevel) {
"log" -> context.log.verbose(message, tag)
"debug" -> context.log.debug(message, tag)
"info" -> context.log.info(message, tag)
"warn" -> context.log.warn(message, tag)
"error" -> context.log.error(message, tag)
}
}
valdiFunction("setEvalFunction") {
if (getSize() < 1) return@valdiFunction
evalFunction = ValdiFunction(getUntyped(0) ?: return@valdiFunction)
context.log.verbose("Set eval function: $evalFunction", "ValdiHooks")
}
fun loadHooks() {
if (!NativeLib.initialized) {
context.log.error("ValdiHooks cannot be loaded without NativeLib")
return
}
val loaderScript = runCatching {
context.fileHandlerManager.getFileHandle(FileHandleScope.VALDI.key, "loader.js").toWrapper().readBytes().toString(Charsets.UTF_8)
}.onFailure {
context.log.error("Failed to load valdi loader script", it)
}.getOrNull() ?: return
context.native.setValdiLoader("""
const i = setInterval(() => {
try {
const _runtimeName = "${if (PurrfectSnap.classCache.nativeBridge.name == "com.snapchat.client.valdi.NativeBridge") "valdi" else "composer"}";
require(_runtimeName + '_core/src/DeviceBridge').getDisplayWidth();
clearInterval(i);
(() => { const _getImportsFunctionName = "$getImportsFunctionName"; $loaderScript })();
} catch (e) {}
}, 200)
""".trimIndent().trim())
}
loadHooks()
if (config.valdiConsole.get()) {
context.inAppOverlay.addCustomComposable {
FilledIconButton(
onClick = {
valdiConsole.show()
},
modifier = Modifier.align(Alignment.TopEnd).padding(top = 100.dp, end = 16.dp)
) {
Icon(Icons.Default.BugReport, contentDescription = "Debug Console")
}
}
}
PurrfectSnap.classCache.nativeBridge.hook("registerNativeModuleFactory", HookStage.BEFORE) { param ->
val moduleFactory = param.argNullable<Any>(1) ?: return@hook
if (moduleFactory.javaClass.getMethod("getModulePath").invoke(moduleFactory)?.toString()?.contains("DeviceBridge") != true) return@hook
Hooker.ephemeralHookObjectMethod(moduleFactory.javaClass, moduleFactory, "loadModule", HookStage.AFTER) { methodParam ->
val result = methodParam.getResult() as? MutableMap<String, Any?> ?: return@ephemeralHookObjectMethod
val importsFunction = newValdiFunction {
pushUntyped(importedFunctions)
true
}
if (importsFunction != null) {
result[getImportsFunctionName] = importsFunction
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More