25 Commits

Author SHA1 Message Date
ΞTΞRNAL
3d83c3116c revert: accidental change 2026-03-20 03:00:24 +05:30
ΞTΞRNAL
2b00898355 v1.4.5 2026-03-20 02:58:58 +05:30
ΞTΞRNAL
975a9a101f fix: duplicate opera download & mark snaps as seen buttons 2026-03-20 01:40:12 +05:30
ΞTΞRNAL
793659c63b fix: friend feed menu button overlapping for group chats 2026-03-19 22:13:45 +05:30
ΞTΞRNAL
5bdc1d7f59 feat(ui): Implement theme reveal transition by Kaladin
feat(ui): Implement theme reveal transition using AGSL
2026-03-18 22:12:24 +05:30
DarkKnight2122
fc66453167 feat(ui): implement theme reveal transition using AGSL 2026-03-18 19:03:37 +05:30
ΞTΞRNAL
84a9d01d6a fix(PR): Multiple fixes by Kaladin
Aphelion theme fixes.
2026-03-18 16:56:06 +05:30
DarkKnight2122
a4b948bba3 UI: Aphelion Changelog and Setup Enhancements 2026-03-18 11:31:55 +05:30
DarkKnight2122
8c720992de UI Fixes for Aphelion and Optimizations 2026-03-18 11:31:55 +05:30
DarkKnight2122
d1fc97cee8 Aphelion Theme Harmonization across tasks page 2026-03-18 11:31:53 +05:30
ΞTΞRNAL
9df49f1bff Merge pull request #114 from RSR1337/RSR
fix: FFMpegProcessor copyright
2026-03-17 11:19:02 +05:30
RSR/
652062769e fix: FFMpegProcessor copyright 2026-03-15 03:14:59 +04:00
ΞTΞRNAL
f1eb833655 fix(PR): Multiple fixes and improvements by RSR
feat:  Video Recording Timer , Auto Skip . fix: Fake Snap Score
2026-03-15 00:28:56 +05:30
RSR/
a27753d8ae feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score 2026-03-14 20:05:59 +04:00
ΞTΞRNAL
da8a261202 feat(PR): Spoof SnapScore by RSR
RSR
2026-03-12 05:20:04 +05:30
ΞTΞRNAL
532ebfe0a7 Merge pull request #112 from particle-box/revert-110-revert-109-RSR
Revert "Revert "feat: Spoof Snap Score""
2026-03-12 05:19:12 +05:30
ΞTΞRNAL
bf7f371022 Revert "Revert "feat: Spoof Snap Score"" 2026-03-12 05:18:59 +05:30
RSR/
15c9a3fd5d Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-12 02:27:47 +04:00
RSR/
bb0ab20a5b fix 2026-03-12 02:27:33 +04:00
ΞTΞRNAL
9a507684ee Merge pull request #110 from particle-box/revert-109-RSR
Revert "feat: Spoof Snap Score"
2026-03-12 03:53:23 +05:30
ΞTΞRNAL
1fbf82b4fb Revert "feat: Spoof Snap Score" 2026-03-12 03:52:54 +05:30
ΞTΞRNAL
53696c26f4 feat(PR): Spoof SnapScore by RSR
feat: Spoof Snap Score
2026-03-12 03:47:24 +05:30
RSR/
381b190535 Merge branch 'particle-box:dev' into RSR 2026-03-12 02:12:51 +04:00
RSR/
50199b052f fix 2026-03-12 02:12:09 +04:00
RSR/
6948d86efc feat: Spoof Snap Score 2026-03-11 19:47:13 +04:00
49 changed files with 3419 additions and 1226 deletions

View File

@@ -12,11 +12,9 @@ import android.provider.MediaStore
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.Constants
@@ -62,6 +60,10 @@ class DownloadProcessor (
private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback
) {
companion object {
private val downloadSemaphore = Semaphore(3)
}
private data class GallerySaveResult(
val uri: Uri,
val alreadyDownloaded: Boolean = false
@@ -441,8 +443,7 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf<Job>()
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
val downloadedMedias = mutableMapOf<InputMedia, File>()
var totalSize = 1L
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
@@ -455,71 +456,72 @@ class DownloadProcessor (
)
}
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
coroutineScope {
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}
}.also { jobs.add(it) }
}
}
jobs.joinAll()
downloadedMedias
return downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
@@ -530,24 +532,24 @@ class DownloadProcessor (
val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.CONVERSION,
inputs = listOf(media.file.absolutePath),
inputs = listOf(media.absolutePath),
output = outputFile
))
media.file.delete()
media.delete()
saveMediaToGallery(pendingTask, outputFile, metadata)
outputFile.delete()
return
}
}
saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
saveMediaToGallery(pendingTask, media, metadata)
media.delete()
return
}
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
@@ -557,7 +559,7 @@ class DownloadProcessor (
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
dashPlaylistFile.outputStream().use {
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
}
@@ -582,7 +584,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -602,7 +604,7 @@ class DownloadProcessor (
// check if the media file has been deleted
if (task.type == TaskType.DOWNLOAD) {
val outputFile = runCatching {
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
}.getOrNull()
if (outputFile != null && !outputFile.exists()) {
@@ -617,113 +619,115 @@ class DownloadProcessor (
return@launch
}
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
downloadSemaphore.withPermit {
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
}
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to it.value
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
zipFile.file.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = DownloadedFile(it, FileType.fromFile(it))
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
shouldMergeOverlay = true
}
oldDownloadedMedias.forEach { (_, value) ->
value.file.delete()
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
shouldMergeOverlay = true
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
}

View File

@@ -106,7 +106,18 @@ class FFMpegProcessor(
if (session.returnCode.isValueSuccess) {
Result.success(session)
} else {
Result.failure(Exception(session.output))
val output = session.output
val errorMsg = when {
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
else -> {
val lines = output.lines().filter { line ->
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
}
lines.lastOrNull()?.take(400)
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
}
}
Result.failure(Exception(errorMsg))
}
)
}, logFunction@{ log ->
@@ -119,11 +130,6 @@ class FFMpegProcessor(
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}
private fun isMediaCodecFailure(output: String): Boolean {
val lower = output.lowercase()
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
}
suspend fun execute(args: Request) {
// load ffmpeg native sync to avoid native crash
synchronized(this) { FFmpegKit.listSessions() }
@@ -140,7 +146,7 @@ class FFMpegProcessor(
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
@@ -215,7 +221,7 @@ class FFMpegProcessor(
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
@@ -232,7 +238,6 @@ class FFMpegProcessor(
}
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
outputArguments += "-c:a" to "pcm_s16le"
}
Action.MERGE_AUDIO_STREAMS -> {
inputArguments.clear()
@@ -241,40 +246,21 @@ class FFMpegProcessor(
args.inputs.forEachIndexed { index, input ->
inputArguments += "-i" to input
val offset = args.inputDelayOffsets?.get(input) ?: 0L
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
if (offset > 0) {
filterParts.append(",adelay=$offset|$offset[a$index];")
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
} else {
filterParts.append(",acopy[a$index];")
filterParts.append("[$index:a]acopy[a$index];")
}
}
args.inputs.indices.forEach { index ->
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-c:a" to "libmp3lame"
outputArguments += "-b:a" to "192k"
outputArguments += "-ar" to "48000"
outputArguments += "-ac" to "2"
}
}
outputArguments += args.output.absolutePath
try {
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} catch (e: Exception) {
val output = e.message.orEmpty()
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
outputArguments -= "-c:v"
outputArguments += "-c:v" to "libx264"
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} else {
throw e
}
}
newFFMpegTask(globalArguments, inputArguments, outputArguments)
}
}

View File

@@ -127,15 +127,26 @@ class TaskManager(
fun getTaskByHash(hash: String?): Task? {
if (hash == null) return null
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
return readTaskFromCursor(cursor)
return runBlocking {
suspendCoroutine { continuation ->
queueExecutor.execute {
runCatching {
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
} else {
continuation.resumeWith(Result.success(null))
}
}
}.onFailure {
continuation.resumeWith(Result.failure(it))
}
}
}
}
return null
}
fun getActiveTasks() = activeTasks
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
val tasks = mutableMapOf<Long, Task>()

View File

@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.ThemePreferences
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
import android.content.IntentFilter
@@ -212,6 +213,16 @@ class MainActivity : ComponentActivity() {
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)

View File

@@ -104,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
import kotlin.math.round
import kotlin.math.PI
import kotlin.math.sin
@@ -122,6 +123,7 @@ class Navigation(
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
val themeRevealState = ThemeRevealState()
@Composable
fun TopBar() {

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.zIndex
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@Immutable
data class FloatingTopBarColors(
@@ -49,6 +50,10 @@ fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
}
}
/**
* Unified Floating Top Bar for Aphelion.
* Handles the signature morphing animation and provides a "Bottom Content" slot.
*/
@Composable
fun FloatingTopBar(
title: String,
@@ -56,16 +61,21 @@ fun FloatingTopBar(
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
scrollOffset: Int = 0,
enableMorph: Boolean = false,
containerAlpha: Float = 1f,
titleAlignment: Alignment.Horizontal = Alignment.Start,
actions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val haptic = LocalHapticFeedback.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val focusFactor by remember(scrollOffset) {
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
val focusFactor by remember(scrollOffset, enableMorph) {
derivedStateOf {
if (!enableMorph) 0f
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
}
}
val morphingParams by remember(focusFactor, statusBarHeight) {
@@ -88,10 +98,10 @@ fun FloatingTopBar(
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped) {
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.9f) {
} else if (focusFactor < 0.5f) {
hasSnapped = false
}
}
@@ -115,7 +125,7 @@ fun FloatingTopBar(
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
@@ -183,87 +193,91 @@ fun FloatingTopBar(
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(
modifier = Modifier
.size(44.dp)
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
}
)
}
}
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
}
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
bottomContent(focusFactor)
}
}
}

View File

@@ -2,17 +2,8 @@ package me.eternal.purrfectsnap.ui.manager.pages
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
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.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -41,13 +32,14 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
@@ -58,95 +50,220 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.download.DownloadProcessor
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.ktx.longHashCode
import me.eternal.purrfectsnap.download.DownloadProcessor
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver
import me.eternal.purrfectsnap.download.FFMpegProcessor
import me.eternal.purrfectsnap.task.*
import me.eternal.purrfectsnap.task.PendingTask
import me.eternal.purrfectsnap.task.PendingTaskListener
import me.eternal.purrfectsnap.task.Task
import me.eternal.purrfectsnap.task.TaskStatus
import me.eternal.purrfectsnap.task.TaskType
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.*
import java.io.File
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.text.Regex
class TasksRootSection : Routes.Route() {
internal var activeTasks by mutableStateOf(listOf<PendingTask>())
internal lateinit var recentTasks: MutableList<Task>
internal var lastFetchedTaskId: Long? by mutableStateOf(null)
internal var recentTasks = mutableStateListOf<Task>()
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
internal var lastFetchedTaskId: Long? by mutableStateOf(null)
internal fun isRecentTasksInitialized(): Boolean = ::recentTasks.isInitialized
internal fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) {
scope.launch(Dispatchers.IO) {
activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList()
}
internal fun fetchActiveTasks(scope: CoroutineScope) {
activeTasks = context.taskManager.getActiveTasks().values.toList()
}
internal fun fetchNewRecentTasks(scope: CoroutineScope = context.coroutineScope) {
scope.launch(Dispatchers.IO) {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
if (tasks.isNotEmpty()) {
lastFetchedTaskId = tasks.keys.last()
val activeTaskIds = activeTasks.map { it.taskId }
recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
internal fun fetchNewRecentTasks() {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
if (tasks.isEmpty()) return
lastFetchedTaskId = tasks.keys.last()
val activeTaskHashes = activeTasks.map { it.task.hash }
val existingHashes = recentTasks.map { it.hash }
val newTasks = tasks.values.filter { it.hash !in activeTaskHashes && it.hash !in existingHashes }
recentTasks.addAll(newTasks)
}
internal fun refreshRecentTasks() {
val tasks = context.taskManager.fetchStoredTasks(Long.MAX_VALUE, limit = 20)
val activeTaskHashes = activeTasks.map { it.task.hash }
val newTasks = tasks.values.filter { it.hash !in activeTaskHashes }
recentTasks.clear()
recentTasks.addAll(newTasks)
lastFetchedTaskId = tasks.keys.lastOrNull()
}
internal fun isRecentTasksInitialized() = true
override val init: () -> Unit = {
recentTasks = mutableStateListOf()
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@TasksRootSection.TasksScreen(nav)
}
}
}
internal fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) {
val firstTask = selection.first().first
@Composable
internal fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
val listState = rememberLazyListState()
var controlsHeight by remember { mutableStateOf(100.dp) }
val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16)
val pendingTask = context.taskManager.createPendingTask(
Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash)
)
pendingTask.status = TaskStatus.RUNNING
fetchActiveTasks()
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
context.coroutineScope.launch {
val filesToMerge = mutableListOf<File>()
LaunchedEffect(computedScrollOffset) {
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
if (isAphelion) {
routes.navigation?.globalScrollOffset = computedScrollOffset
} else {
routes.navigation?.globalScrollOffset = 0
}
}
selection.forEach { (task, documentFile) ->
val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also {
it.deleteOnExit()
}
DisposableEffect(Unit) {
onDispose {
routes.navigation?.globalScrollOffset = 0
}
}
runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
val length = documentFile.length().toFloat()
tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt())
LaunchedEffect(Unit) {
refreshRecentTasks()
while (true) {
fetchActiveTasks(this)
delay(2000)
}
}
val shouldFetchMore by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
lastVisibleItemIndex > (totalItemsNumber - 5)
}
}
LaunchedEffect(shouldFetchMore) {
if (shouldFetchMore) {
fetchNewRecentTasks()
}
}
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().background(PurrfectPalette.backgroundGradient))
Column(modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 16.dp,
bottom = routes.bottomPadding + 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
item {
AphelionTasksEmptyState(translation["no_tasks"])
}
outputStream.flush()
filesToMerge.add(tempFile)
}
items(activeTasks, key = { it.task.hash }) { pendingTask ->
TaskCard(
modifier = Modifier.fillMaxWidth(),
task = pendingTask.task,
pendingTask = pendingTask
)
}
items(recentTasks.filter { task -> activeTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
TaskCard(
modifier = Modifier.fillMaxWidth(),
task = task
)
}
}
}.onFailure {
pendingTask.fail("Failed to copy file $documentFile to $tempFile")
filesToMerge.forEach { it.delete() }
return@launch
}
Spacer(Modifier.height(12.dp))
}
val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also {
it.deleteOnExit()
Column(modifier = Modifier.headerHeightTracker { controlsHeight = it }) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.tasks"],
subtitle = translation["tasks_tagline"],
scrollOffset = computedScrollOffset,
enableMorph = true,
actions = {
topBarActions()
}
)
}
}
}
internal fun mergeSelection(files: List<Pair<Task, DocumentFile>>) {
val taskHash = System.nanoTime().toString(36)
val firstTask = files.first().first
val pendingTask = context.taskManager.createPendingTask(Task(
type = TaskType.DOWNLOAD,
title = firstTask.title,
author = firstTask.author,
hash = taskHash
)).apply {
status = TaskStatus.RUNNING
}
context.coroutineScope.launch(Dispatchers.IO) {
val filesToMerge = files.mapNotNull { (_, documentFile) ->
val tempFile = File.createTempFile("merge", ".tmp")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile
}
val mergedFile = File.createTempFile("merged", ".mp4")
runCatching {
context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString()))
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
@@ -202,7 +319,6 @@ class TasksRootSection : Routes.Route() {
}
}
activeTasks = listOf()
context.taskManager.getActiveTasks().clear()
}
}
@@ -213,7 +329,6 @@ class TasksRootSection : Routes.Route() {
message: String,
showDeleteFiles: Boolean,
deleteFilesChecked: Boolean,
tasksTranslation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper,
onToggleDeleteFiles: (Boolean) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
@@ -221,6 +336,7 @@ class TasksRootSection : Routes.Route() {
if (!visible) return
val dialogShape = RoundedCornerShape(24.dp)
val haptic = LocalHapticFeedback.current
val borderGradient = remember {
Brush.linearGradient(
listOf(
@@ -238,24 +354,44 @@ class TasksRootSection : Routes.Route() {
shadowElevation = 20.dp,
border = BorderStroke(1.dp, borderGradient)
) {
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay, dialogShape)) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
Box(modifier = Modifier.size(56.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.38f), PurrfectPalette.glowSecondary.copy(alpha = 0.32f)))), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Filled.Warning, contentDescription = null, modifier = Modifier.size(28.dp), tint = Color.White)
}
}
Column {
Text(text = title, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(text = message, fontSize = 13.sp, color = PurrfectPalette.textSecondary)
}
Box(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, dialogShape)
) {
Column(
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = Color(0xFFFF6B9B),
modifier = Modifier.size(28.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White
)
}
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = PurrfectPalette.textSecondary
)
if (showDeleteFiles) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.04f),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.05f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
@@ -263,14 +399,20 @@ class TasksRootSection : Routes.Route() {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleDeleteFiles(!deleteFilesChecked) }
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleDeleteFiles(!deleteFilesChecked)
}
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Checkbox(
checked = deleteFilesChecked,
onCheckedChange = { onToggleDeleteFiles(it) },
onCheckedChange = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleDeleteFiles(it)
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White,
@@ -279,26 +421,47 @@ class TasksRootSection : Routes.Route() {
)
Column {
Text(
text = tasksTranslation["delete_files_option"],
text = context.translation["delete_files_option"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Permanently remove the original files from storage",
text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.08f), contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Text(text = context.translation["button.cancel"])
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(context.translation["button.negative"])
}
Button(onClick = { onConfirm() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
Text(text = context.translation["button.positive"], fontWeight = FontWeight.Bold)
Button(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onConfirm()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(context.translation["button.positive"])
}
}
}
@@ -310,13 +473,38 @@ class TasksRootSection : Routes.Route() {
@Composable
internal fun TasksEmptyState(text: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 60.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(shape = CircleShape, color = Color.White.copy(alpha = 0.08f), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) {
Box(modifier = Modifier.size(58.dp).background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.32f), PurrfectPalette.glowSecondary.copy(alpha = 0.28f))), CircleShape), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.CheckCircle, contentDescription = text, tint = Color.White)
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Box(
modifier = Modifier
.size(58.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = text,
tint = Color.White
)
}
}
Text(
@@ -327,6 +515,67 @@ class TasksRootSection : Routes.Route() {
}
}
@Composable
internal fun AphelionTasksEmptyState(text: String) {
TasksEmptyState(text)
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
@Composable
internal fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -334,26 +583,46 @@ class TasksRootSection : Routes.Route() {
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
val haptic = LocalHapticFeedback.current
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) {
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(defaultValue = null as DocumentFile?, keys = arrayOf(taskStatus.name)) {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
}
val listener = remember { PendingTaskListener(
onStateChange = { taskStatus = it },
onProgress = { label, progress -> taskProgressLabel = label; taskProgress = progress }
onStateChange = {
taskStatus = it
},
onProgress = { label, progress ->
taskProgressLabel = label
taskProgress = progress
}
) }
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
LaunchedEffect(Unit) {
pendingTask?.addListener(listener)
}
DisposableEffect(Unit) {
onDispose {
pendingTask?.removeListener(listener)
}
}
fun toggleSelection() {
if (isSelected) { taskSelection.removeIf { it.first == task }; return }
if (isSelected) {
taskSelection.removeIf { it.first == task }
return
}
taskSelection.add(task to documentFile)
}
@@ -374,13 +643,29 @@ class TasksRootSection : Routes.Route() {
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = { if (taskSelection.isNotEmpty()) toggleSelection() else openFile() },
onLongPress = { if (taskSelection.isNotEmpty()) openFile() else toggleSelection() }
onTap = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
)
}
.let {
if (isSelected) {
it.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large).clip(MaterialTheme.shapes.large)
it
.border(2.dp, PurrfectPalette.glowSecondary, MaterialTheme.shapes.large)
.clip(MaterialTheme.shapes.large)
} else it
}
@@ -398,87 +683,163 @@ class TasksRootSection : Routes.Route() {
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
else -> Icons.Filled.Info
}
val chipColors = when {
isActive -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.08f),
labelColor = Color.White
)
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
labelColor = Color.White
)
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.06f),
labelColor = PurrfectPalette.textSecondary
)
else -> AssistChipDefaults.assistChipColors()
}
Surface(
modifier = cardModifier.fillMaxWidth().padding(vertical = 4.dp),
modifier = cardModifier,
shape = MaterialTheme.shapes.large,
color = Color.White.copy(alpha = 0.04f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(modifier = Modifier.padding(14.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Box(modifier = Modifier.size(54.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.06f)).border(1.dp, Color.White.copy(alpha = 0.1f), CircleShape), contentAlignment = Alignment.Center) {
Icon(imageVector = if (task.type == TaskType.DOWNLOAD) Icons.Default.Download else Icons.Default.Transform, contentDescription = null, tint = Color.White.copy(alpha = 0.6f), modifier = Modifier.size(24.dp))
Box(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(14.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
modifier = Modifier.size(56.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
)
),
contentAlignment = Alignment.Center
) {
documentFile?.let { doc ->
if (documentFileMimeType.contains("image")) {
Image(
painter = rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.data(doc.uri)
.size(120)
.crossfade(true)
.build()
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(18.dp))
)
} else {
Icon(
imageVector = when {
!isDocumentFileReadable -> Icons.Filled.DeleteOutline
documentFileMimeType.contains("video") -> Icons.Filled.Videocam
documentFileMimeType.contains("audio") -> Icons.Filled.MusicNote
else -> Icons.Filled.FileCopy
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
} ?: run {
Icon(
imageVector = when (task.type) {
TaskType.DOWNLOAD -> Icons.Filled.Download
TaskType.CHAT_ACTION -> Icons.Filled.ChatBubble
TaskType.SCHEDULED_SEND -> Icons.Filled.Schedule
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
task.author?.takeIf { it != "null" }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = PurrfectPalette.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isActive) {
taskProgressLabel?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White)
}
if (taskProgress != -1) {
LinearProgressIndicator(
progress = { taskProgress.toFloat() / 100f },
strokeCap = StrokeCap.Round,
modifier = Modifier.fillMaxWidth().height(6.dp),
color = PurrfectPalette.glowSecondary,
trackColor = Color.White.copy(alpha = 0.12f)
)
}
} else {
chipLabel?.let { label ->
AssistChip(
onClick = {},
enabled = false,
leadingIcon = chipIcon?.let { { Icon(it, null, modifier = Modifier.size(14.dp)) } },
label = { Text(label, fontSize = 11.sp) },
colors = chipColors,
shape = RoundedCornerShape(10.dp)
)
}
}
}
if (isActive) {
CircularProgressIndicator(progress = { (taskProgress / 100f).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxSize(), color = PurrfectPalette.glowPrimary, strokeWidth = 3.dp, trackColor = Color.Transparent, strokeCap = StrokeCap.Round)
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
pendingTask?.cancel()
}) {
Icon(Icons.Filled.Close, null, tint = Color(0xFFFF6B9B))
}
} else if (taskStatus == TaskStatus.SUCCESS) {
Icon(Icons.Filled.Check, null, tint = PurrfectPalette.glowSecondary)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(text = task.title, fontSize = 15.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = taskProgressLabel ?: task.author ?: "", fontSize = 12.sp, color = PurrfectPalette.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (chipLabel != null || chipIcon != null) {
AssistChip(onClick = {}, label = { chipLabel?.let { Text(it) } }, leadingIcon = { chipIcon?.let { Icon(it, null, modifier = Modifier.size(18.dp)) } }, colors = AssistChipDefaults.assistChipColors(labelColor = Color.White, leadingIconContentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), shape = CircleShape)
}
}
}
}
override val init: () -> Unit = {
recentTasks = mutableStateListOf()
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
}
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@TasksRootSection.TasksScreen(nav) } }
}
override val topBarActions: @Composable RowScope.() -> Unit = {
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.isNotEmpty()) {
val hapticFeedback = LocalHapticFeedback.current
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); mergeSelection(taskSelection.toList().also { taskSelection.clear() }.map { it.first to it.second!! }) }, icon = Icons.Filled.Merge, text = translation["merge_button"])
}
}
IconButton(onClick = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); showConfirmDialog = true }) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText ?: "",
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.key
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
@@ -52,7 +54,9 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -65,13 +69,13 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.config.*
import me.eternal.purrfectsnap.common.config.FeatureNotice
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.rememberRouteLazyListState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.*
import org.json.JSONArray
@@ -119,7 +123,7 @@ class FeaturesRootSection : Routes.Route() {
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
) {
containers[it.key.name] = (it.key to it.value).toPropertyPair()
containers[it.key.name] = (it.key to it.value).toPropertyPair() as PropertyPair<Any>
queryContainerRecursive(it.value.get() as ConfigContainer)
}
}
@@ -143,86 +147,22 @@ class FeaturesRootSection : Routes.Route() {
return !propertyKey.params.flags.contains(ConfigFlag.HIDDEN)
}
internal data class SearchEntry(val keyword: String, val tokens: List<String>)
internal fun buildSearchEntries(): List<SearchEntry> {
return allProperties.keys.mapNotNull { key ->
if (!isSearchVisibleProperty(key)) return@mapNotNull null
val name = context.translation[key.propertyName()]
val description = context.translation[key.propertyDescription()]
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
if (tokens.isEmpty()) null else SearchEntry(keyword = name ?: key.name, tokens = tokens)
}
}
internal fun levenshtein(a: String, b: String): Int {
if (a == b) return 0
if (a.isEmpty()) return b.length
if (b.isEmpty()) return a.length
val prev = IntArray(b.length + 1) { it }
val curr = IntArray(b.length + 1)
for (i in a.indices) {
curr[0] = i + 1
for (j in b.indices) {
val cost = if (a[i] == b[j]) 0 else 1
curr[j + 1] = min(
min(curr[j] + 1, prev[j + 1] + 1),
prev[j] + cost
)
internal fun getFolderReadablePath(context: android.content.Context, folderUri: String?): String? {
if (folderUri == null) return null
return try {
val uri = android.net.Uri.parse(folderUri)
val path = uri.path ?: return folderUri
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else {
folderUri
}
prev.indices.forEach { prev[it] = curr[it] }
} catch (e: Exception) {
folderUri
}
return curr[b.length]
}
internal fun similarityScore(query: String, target: String): Float {
val q = query.lowercase()
val t = target.lowercase()
val maxLen = max(q.length, t.length)
if (maxLen == 0) return 1f
val dist = levenshtein(q, t)
return 1f - (dist.toFloat() / maxLen.toFloat())
}
internal fun fuzzySuggest(query: String, entries: List<SearchEntry>): List<String> {
val q = query.trim()
if (q.length < 2) return emptyList()
return entries.map { entry ->
val best = entry.tokens.maxOfOrNull { similarityScore(q, it) } ?: 0f
best to entry.keyword
}.filter { it.first >= 0.45f }
.sortedWith(compareByDescending<Pair<Float, String>> { it.first }.thenBy { it.second.length })
.map { it.second }
.distinct()
.take(6)
}
internal fun loadSearchHistory(): List<String> {
return context.sharedPreferences
.getString("features_search_history", "")
?.split("|")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?: emptyList()
}
internal fun saveSearchHistory(history: List<String>) {
context.sharedPreferences.edit()
.putString("features_search_history", history.joinToString("|"))
.apply()
}
internal fun upsertHistory(term: String, history: SnapshotStateList<String>) {
val cleaned = term.trim()
if (cleaned.isEmpty()) return
val existingIndex = history.indexOfFirst { it.equals(cleaned, ignoreCase = true) }
if (existingIndex >= 0) history.removeAt(existingIndex)
history.add(0, cleaned)
while (history.size > 12) history.removeLast()
saveSearchHistory(history)
}
internal fun navigateToMainRoot() {
fun navigateToMainRoot() {
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
.setLaunchSingleTop(true)
@@ -230,7 +170,7 @@ class FeaturesRootSection : Routes.Route() {
)
}
internal fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) {
routes.activityLauncher.let(block)
}
@@ -243,6 +183,9 @@ class FeaturesRootSection : Routes.Route() {
}
key(themeId) {
LaunchedEffect(themeId) {
routes.navigation?.globalScrollOffset = 0
}
with(ManagerTheme.fromId(themeId).theme) {
this@FeaturesRootSection.FeaturesScreen(nav)
}
@@ -387,7 +330,7 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) {
var showDialog by remember { mutableStateOf(false) }
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
@@ -554,20 +497,17 @@ class FeaturesRootSection : Routes.Route() {
}
}
Icon(Icons.Filled.AttachFile, contentDescription = null)
return
}
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
IconButton(onClick = registerClickCallback {
activityLauncher {
chooseFolder { uri ->
propertyValue.setAny(uri)
persistConfig()
}
routes.activityLauncher.chooseFolder { uri ->
propertyValue.setAny(uri)
persistConfig()
}
}.let { { it.invoke(true) } }) {
Icon(Icons.Filled.FolderOpen, contentDescription = null)
}) {
Icon(Icons.Filled.FolderOpen, contentDescription = null, tint = Color.White)
}
return
}
@@ -578,7 +518,7 @@ class FeaturesRootSection : Routes.Route() {
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = registerClickCallback {
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
@@ -601,7 +541,7 @@ class FeaturesRootSection : Routes.Route() {
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.widthIn(0.dp, 120.dp),
modifier = Modifier.widthIn(0.dp, 120.dp).clickable { showDialog = true },
text = (propertyValue.get() as Pair<*, *>).let {
"${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}"
}
@@ -618,7 +558,7 @@ class FeaturesRootSection : Routes.Route() {
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.widthIn(0.dp, 120.dp),
modifier = Modifier.widthIn(0.dp, 120.dp).clickable { showDialog = true },
text = (propertyValue.getNullable() as? String ?: "null").let {
property.key.propertyOption(context.translation, it)
}
@@ -643,41 +583,39 @@ class FeaturesRootSection : Routes.Route() {
}
}
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
if (dataType == DataProcessors.Type.INTEGER ||
dataType == DataProcessors.Type.FLOAT) {
ValueGlowChip(
text = propertyValue.get().toString(),
onClick = it
)
} else {
val isMessageListProperty = property.key.name.endsWith("_messages")
if (isMessageListProperty) {
val messageCount = try {
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
messageList.size
} catch (e: Exception) {
1
}
val click = registerClickCallback { showDialog = true }
if (dataType == DataProcessors.Type.INTEGER ||
dataType == DataProcessors.Type.FLOAT) {
ValueGlowChip(
text = propertyValue.get().toString(),
onClick = click
)
} else {
val isMessageListProperty = property.key.name.endsWith("_messages")
if (isMessageListProperty) {
val messageCount = try {
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
messageList.size
} catch (e: Exception) {
1
}
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = translation.format("search_results_count", "count" to messageCount.toString()),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
} else {
IconButton(onClick = it) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
}
Surface(
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.clickable { click() }
) {
Text(
text = translation.format("search_results_count", "count" to messageCount.toString()),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
} else {
IconButton(onClick = click) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
}
}
}
@@ -690,9 +628,8 @@ class FeaturesRootSection : Routes.Route() {
}
}
registerDialogOnClickCallback().let { { it.invoke(true) } }.also {
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
}
val click = registerClickCallback { showDialog = true }
CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) })
}
DataProcessors.Type.CONTAINER -> {
@@ -786,7 +723,8 @@ class FeaturesRootSection : Routes.Route() {
@Composable
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
var clickCallback by remember { mutableStateOf<ClickCallback?>(null) }
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
val noticeColorMap = remember {
mapOf(
FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87),
@@ -821,7 +759,7 @@ class FeaturesRootSection : Routes.Route() {
indication = null
) {
onOpen?.invoke()
clickCallback?.invoke(true)
clickCallback?.invoke()
}
.scaleOnPress(interactionSource),
shape = cardShape,
@@ -932,6 +870,8 @@ class FeaturesRootSection : Routes.Route() {
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun FloatingControls(
@@ -943,11 +883,13 @@ class FeaturesRootSection : Routes.Route() {
onSearchQueryChange: (String) -> Unit,
onBack: (() -> Unit)? = null,
scrollOffset: Int = 0,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onHeightMeasured: (androidx.compose.ui.unit.Dp) -> Unit = {}
) {
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var showSearchBar by rememberSaveable { mutableStateOf(isSearchResults) }
val focusRequester = remember { FocusRequester() }
val isOverlay = remember { context.sharedPreferences.getBoolean("overlay_active", false) }
val isOverlay = activeSectionTitle != null
var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
@@ -1031,6 +973,7 @@ class FeaturesRootSection : Routes.Route() {
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
context.config.reset()
context.config.writeConfig()
context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"] ?: "Reset successful")
showResetConfirmationDialog = false
},
@@ -1065,29 +1008,35 @@ class FeaturesRootSection : Routes.Route() {
val actions = remember {
listOf(
Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDialog = true
{
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDialog = true
}
},
Triple(translation["import_option"] ?: "Import", Icons.Filled.FileDownload) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
activityLauncher {
openFile("application/json") { uriString ->
runCatching {
val uri = android.net.Uri.parse(uriString)
context.androidContext.contentResolver.openInputStream(uri)?.use {
routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
{
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
activityLauncher {
openFile("application/json") { uriString ->
runCatching {
val uri = android.net.Uri.parse(uriString)
context.androidContext.contentResolver.openInputStream(uri)?.use {
routes.configJsonForImport = it.readBytes().toString(Charsets.UTF_8)
routes.navController.navigate(Routes.CONFIG_IMPORT_CONFIRMATION_ROUTE)
}
}.onFailure { err ->
context.log.error("Failed to read config file", err)
context.longToast(translation.format("config_import_failure_toast", "error" to (err.message ?: "Unknown")))
}
}.onFailure { err ->
context.log.error("Failed to read config file", err)
context.longToast(translation.format("config_import_failure_toast", "error" to (err.message ?: "Unknown")))
}
}
}
},
Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showResetConfirmationDialog = true
{
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showResetConfirmationDialog = true
}
}
)
}
@@ -1112,136 +1061,229 @@ class FeaturesRootSection : Routes.Route() {
}
}
Column(modifier = modifier) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = headerTitle,
subtitle = if (showSearchBar) null else subtitleText,
onBack = onBack,
scrollOffset = scrollOffset,
actions = {
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
if (keywordValue.text.isEmpty()) {
updateSearch("", record = false)
} else {
updateSearch(keywordValue.text, record = false)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
leadingIcon = {
Column(modifier = modifier.headerHeightTracker { onHeightMeasured(it) }) {
if (isAphelion) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = headerTitle,
subtitle = if (showSearchBar) null else subtitleText,
onBack = onBack,
scrollOffset = scrollOffset,
enableMorph = true,
actions = {
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
if (keywordValue.text.isEmpty()) {
updateSearch("", record = false)
} else {
updateSearch(keywordValue.text, record = false)
}
},
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
},
trailingIcon = {
if (searchValue.text.isNotEmpty()) {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
searchValue = TextFieldValue("", TextRange(0))
updateSearch("", record = false)
if (isSearchResults) {
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
showSearchBar = false
}
}) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White)
}
}
},
keyboardActions = KeyboardActions(
onDone = {
updateSearch(searchValue.text, record = true)
}
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.65f),
focusedPlaceholderColor = Color(0xFFE0DCFF),
unfocusedPlaceholderColor = Color(0xFFE0DCFF),
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f),
focusedTrailingIconColor = Color.White,
unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f)
)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = true
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
},
trailingIcon = {
if (searchValue.text.isNotEmpty()) {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
searchValue = TextFieldValue("", TextRange(0))
updateSearch("", record = false)
if (isSearchResults) {
if (isOverlay) {
routes.navController.popBackStack(routeInfo.id, false)
}
} else {
showSearchBar = false
}
}) {
Icon(Icons.Filled.Close, contentDescription = null, tint = Color.White)
}
}
if (context.activity != null) {
Box {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDropdownMenu = !showExportDropdownMenu
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = PurrfectPalette.glowSecondary
)
}
DropdownMenu(
expanded = showExportDropdownMenu,
onDismissRequest = { showExportDropdownMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
actions.forEach { (name, icon, action) ->
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = name ?: "", color = Color.White) },
onClick = {
action()()
showExportDropdownMenu = false
},
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
}
}
},
keyboardActions = KeyboardActions(
onDone = {
updateSearch(searchValue.text, record = true)
}
),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
disabledTextColor = Color.White.copy(alpha = 0.65f),
focusedPlaceholderColor = Color(0xFFE0DCFF),
unfocusedPlaceholderColor = Color(0xFFE0DCFF),
focusedLeadingIconColor = Color.White,
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.9f),
focusedTrailingIconColor = Color.White,
unfocusedTrailingIconColor = Color.White.copy(alpha = 0.9f)
)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = true
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
}
}
}
)
} else {
val topBarShape = RoundedCornerShape(26.dp)
val topBarBackground = remember { PurrfectPalette.cardOverlay }
val topBarBorder = remember {
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
}
if (context.activity != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 12.dp)
.zIndex(1f)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = topBarShape,
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, topBarBorder),
tonalElevation = 0.dp,
shadowElevation = 8.dp
) {
Box {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDropdownMenu = !showExportDropdownMenu
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = PurrfectPalette.glowSecondary
)
}
DropdownMenu(
expanded = showExportDropdownMenu,
onDismissRequest = { showExportDropdownMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
Box(
modifier = Modifier
.matchParentSize()
.clip(topBarShape)
.background(topBarBackground)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
actions.forEach { (name, icon, action) ->
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = name ?: "", color = Color.White) },
onClick = {
action()
showExportDropdownMenu = false
},
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = context.translation["common.back"],
tint = Color.White
)
}
}
if (showSearchBar) {
TextField(
value = searchValue,
onValueChange = { keywordValue ->
searchValue = keywordValue
updateSearch(keywordValue.text, record = false)
},
modifier = Modifier.weight(1f).focusRequester(focusRequester),
singleLine = true,
placeholder = { Text(text = translation["search_button"] ?: "Search", color = Color(0xFFE0DCFF)) },
colors = TextFieldDefaults.colors(focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, cursorColor = Color.White, focusedTextColor = Color.White, unfocusedTextColor = Color.White)
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
} else {
Column(modifier = Modifier.weight(1f)) {
Text(
text = headerTitle,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = subtitleText,
color = Color(0xFFCEC8FF),
fontSize = 12.sp
)
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = !showSearchBar
}) {
Icon(imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search, contentDescription = null, tint = Color.White)
}
}
}
}
}
)
}
if (showSearchBar && combinedSuggestions.isNotEmpty()) {
Surface(
@@ -1310,13 +1352,15 @@ class FeaturesRootSection : Routes.Route() {
) {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
val listState = rememberRouteLazyListState(stateKey)
val listState = rememberLazyListState()
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
val globalSearchProperties = remember(enableGlobalSearch) {
if (enableGlobalSearch) {
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() }
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() } as List<PropertyPair<Any>>
} else {
emptyList()
}
@@ -1396,7 +1440,7 @@ class FeaturesRootSection : Routes.Route() {
onSearchQueryChange = { liveSearchQuery = it },
onBack = onBack,
scrollOffset = computedScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
onHeightMeasured = { controlsHeight = it }
)
}
}
@@ -1537,7 +1581,7 @@ class FeaturesRootSection : Routes.Route() {
) {
PropertiesView(
properties = remember {
configContainer.properties.map { (it.key to it.value).toPropertyPair() }.filter {
configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair<Any> }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
}
},
@@ -1549,4 +1593,75 @@ class FeaturesRootSection : Routes.Route() {
onBack = onBack
)
}
// Ported Reference Fuzzy Logic
internal data class SearchEntry(val keyword: String, val tokens: List<String>)
internal fun buildSearchEntries(): List<SearchEntry> {
return allProperties.keys.mapNotNull { key ->
if (!isSearchVisibleProperty(key)) return@mapNotNull null
val name = context.translation[key.propertyName()]
val description = context.translation[key.propertyDescription()]
val tokens = listOfNotNull(name, description, key.name).map { it.trim() }.filter { it.isNotEmpty() }
if (tokens.isEmpty()) null else SearchEntry(keyword = name ?: key.name, tokens = tokens)
}
}
private fun levenshtein(a: String, b: String): Int {
if (a == b) return 0
if (a.isEmpty()) return b.length
if (b.isEmpty()) return a.length
val prev = IntArray(b.length + 1) { it }
val curr = IntArray(b.length + 1) { it }
for (i in a.indices) {
curr[0] = i + 1
for (j in b.indices) {
val cost = if (a[i] == b[j]) 0 else 1
curr[j + 1] = min(min(curr[j] + 1, prev[j + 1] + 1), prev[j] + cost)
}
prev.indices.forEach { prev[it] = curr[it] }
}
return curr[b.length]
}
private fun similarityScore(query: String, target: String): Float {
val q = query.lowercase()
val t = target.lowercase()
val maxLen = max(q.length, t.length)
if (maxLen == 0) return 1f
val dist = levenshtein(q, t)
return 1f - (dist.toFloat() / maxLen.toFloat())
}
internal fun fuzzySuggest(query: String, entries: List<SearchEntry>): List<String> {
val q = query.trim()
if (q.length < 2) return emptyList()
return entries.map { entry ->
val best = entry.tokens.maxOfOrNull { similarityScore(q, it) } ?: 0f
best to entry.keyword
}.filter { it.first >= 0.45f }
.sortedWith(compareByDescending<Pair<Float, String>> { it.first }.thenBy { it.second.length })
.map { it.second }
.distinct()
.take(6)
}
internal fun loadSearchHistory(): List<String> {
return context.sharedPreferences.getString("features_search_history", "")
?.split("|")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
}
internal fun saveSearchHistory(history: List<String>) {
context.sharedPreferences.edit().putString("features_search_history", history.joinToString("|")).apply()
}
internal fun upsertHistory(term: String, history: SnapshotStateList<String>) {
val cleaned = term.trim()
if (cleaned.isEmpty()) return
val existingIndex = history.indexOfFirst { it.equals(cleaned, ignoreCase = true) }
if (existingIndex >= 0) history.removeAt(existingIndex)
history.add(0, cleaned)
while (history.size > 12) history.removeLast()
saveSearchHistory(history.toList())
}
}

View File

@@ -49,7 +49,7 @@ class HomeAbout : Routes.Route() {
}
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"])
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
}
key(themeId) {

View File

@@ -254,7 +254,7 @@ class HomeLogs : Routes.Route() {
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) },
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
onClick = {
onExport()
showMenu = false
@@ -268,7 +268,7 @@ class HomeLogs : Routes.Route() {
tint = Color(0xFFFF9CAB)
)
},
text = { Text(text = translation["clear_button"] ?: "Clear", color = Color.White) },
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
onClick = {
onClear()
showMenu = false

View File

@@ -776,3 +776,7 @@ class HomeRootSection : Routes.Route() {
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInteropFilter
@@ -155,7 +150,6 @@ class RetroGameScreen : Routes.Route() {
}
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"]?:"")
resetGame()
while (true) {
delay(16)
@@ -197,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
Box(
modifier = Modifier
.fillMaxSize()
@@ -208,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() }
)
if (isAphelion) {
FloatingTopBar(
title = translation["title"] ?: "Retro Flight",
onBack = { routes.navController.popBackStack() }
)
} else {
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["title"] ?: "",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier

View File

@@ -899,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -568,3 +568,7 @@ class SocialRootSection : Routes.Route() {
}
}
}

View File

@@ -51,7 +51,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
}
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
val horizontalPadding = 24.dp
val horizontalPadding = 24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
@@ -74,10 +74,12 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = bottomPadding + 4.dp),
.padding(bottom = bottomPadding + 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
@@ -111,9 +113,6 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
}
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
@@ -216,7 +215,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -231,6 +230,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}

View File

@@ -462,6 +462,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val scrollState = rememberScrollState()
var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) }
var showChangelogDialog by rememberSaveable { mutableStateOf(false) }
var changelogText by rememberSaveable { mutableStateOf<String?>(null) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
var announcementsLoading by remember { mutableStateOf(false) }
@@ -488,6 +492,33 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
}
fun loadChangelog() {
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
changelogError = e.message ?: "Failed to fetch"
changelogLoading = false
}
}
}
}
fun loadAnnouncements() {
if (announcementsText != null) return
announcementsLoading = true
@@ -624,7 +655,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true } },
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
@@ -759,13 +790,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
if (showChangelogDialog) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "",
text = translation["changelog_dialog_empty"] ?: "",
icon = Icons.Filled.Info,
confirmButtonText = translation["changelog_dialog_update_button"] ?: "",
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "", icon = Icons.Filled.Info,
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "",
onDismiss = { showChangelogDialog = false }
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
onDismiss = { showChangelogDialog = false },
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (changelogLoading) CircularProgressIndicator(color = Color.White)
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}

View File

@@ -122,6 +122,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {

View File

@@ -8,7 +8,8 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.AnimatedVisibility
@@ -46,11 +47,17 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.ui.util.openFile
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
@@ -58,8 +65,10 @@ import java.net.URLEncoder
@Composable
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var controlsHeight by remember { mutableStateOf(100.dp) }
var showResetSetupDialog by remember { mutableStateOf(false) }
@@ -69,8 +78,15 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
LaunchedEffect(computedScrollOffset) {
routes.navigation?.globalScrollOffset = computedScrollOffset
}
Box(
@@ -106,162 +122,199 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = routes.bottomPadding + 24.dp)
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
Switch(
checked = currentThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
item {
Spacer(Modifier.height(controlsHeight))
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
// UPDATES
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
item {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch(
checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
val newId = if (isAphelion) "APHELION" else "LEGACY"
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Ensure it finishes its work before scope potentially closes
}
},
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
// UPDATES
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
}
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
}
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
}
@@ -269,10 +322,11 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
scrollOffset = computedScrollOffset,
enableMorph = true,
titleAlignment = Alignment.CenterHorizontally,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {

View File

@@ -58,12 +58,14 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
val scrollState = rememberLazyListState()
val haptic = LocalHapticFeedback.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
@@ -95,98 +97,39 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
actions = {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White)
Text(
text = translation.format("running_count", "count" to activeTasks.size.toString()),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp
)
}
}
Spacer(Modifier.width(8.dp))
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.1f),
modifier = Modifier
.padding(end = 8.dp)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
}
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Merge,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
translation["merge_button"] ?: "Merge",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
Spacer(modifier = Modifier.height(8.dp))
// The "Structured Glass" Container (1:1 with build 33a7e8f)
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 0.dp,
bottom = routes.bottomPadding
start = 10.dp,
end = 10.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
@@ -209,6 +152,69 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
}
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Filled.PlaylistAddCheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = activeTasks.size.toString(),
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 12.sp
)
}
}
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
)
}
if (showConfirmDialog) {
@@ -222,11 +228,10 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText ?: "",
message = messageText ?: "",
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
@@ -291,11 +296,14 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(
defaultValue = null as DocumentFile?,
keys = arrayOf(taskStatus.key)
keys = arrayOf(taskStatus.name)
) {
DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
@@ -409,48 +417,32 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
var loadFailed by remember { mutableStateOf(false) }
documentFile?.let { doc ->
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
} ?: run {
when (task.type) {
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = null)
TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = null)
TaskType.SCHEDULED_SEND -> {
val active = !taskStatus.isFinalStage()
val rotation = if (active) {
val transition = rememberInfiniteTransition(label = "scheduled_send")
transition.animateFloat(initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)), label = "rotation").value
} else 0f
Box(modifier = Modifier.size(50.dp).clip(CircleShape).background(if (active) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.25f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f))) else SolidColor(Color.White.copy(alpha = 0.06f))), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(28.dp).rotate(rotation), tint = if (active) PurrfectPalette.glowSecondary else PurrfectPalette.textSecondary)
val doc = documentFile
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
}

View File

@@ -45,9 +45,18 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.core.view.drawToBitmap
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -610,6 +619,8 @@ object LegacyTheme : ThemeContract {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
@@ -657,10 +668,11 @@ object LegacyTheme : ThemeContract {
}
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
@@ -696,19 +708,48 @@ object LegacyTheme : ThemeContract {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp)
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
Switch(
checked = currentThemeId == "APHELION",
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch( checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val newId = if (isAphelion) "APHELION" else "LEGACY"
context.config.root.global.uiSettings.managerTheme.set(newId)
context.config.writeConfig()
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Wait for write to finish
}
},
modifier = Modifier.padding(end = 26.dp),
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}
@@ -869,10 +910,27 @@ object LegacyTheme : ThemeContract {
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About",
onBack = { routes.navController.popBackStack() }
)
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier.padding(horizontal = pagePadding, vertical = 12.dp).fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
border = BorderStroke(1.dp, Brush.linearGradient(listOf(Color.White.copy(alpha = 0.12f), Color.White.copy(alpha = 0.05f)))),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About", fontWeight = FontWeight.ExtraBold, fontSize = 18.sp)
Spacer(modifier = Modifier.width(48.dp))
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -893,20 +951,17 @@ object LegacyTheme : ThemeContract {
text = translation["about_title"] ?: "About",
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
color = Color.White,
fontFamily = avenirNext,
modifier = Modifier.clickable(interactionSource = tapSource, indication = null) {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.value > 1500L) { tapCount.intValue = 0 }
tapCount.intValue += 1
lastTapTime.value = now
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
}
if (tapCount.intValue >= 5) { tapCount.intValue = 0; routes.retroGame.navigate() }
}
)
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = PurrfectPalette.textSecondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
@@ -929,7 +984,7 @@ object LegacyTheme : ThemeContract {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(text = translation["about_story_title"] ?: "Our Story", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(text = aboutStory, fontSize = 14.sp, color = PurrfectPalette.textSecondary, lineHeight = 20.sp)
Text(text = aboutStory, fontSize = 14.sp, color = Color(0xFFD9D3FF), lineHeight = 20.sp)
}
}
@@ -967,6 +1022,53 @@ object LegacyTheme : ThemeContract {
}
}
@Composable
internal fun HomeAbout.DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
Surface(
modifier = modifier.scaleOnPress(tapSource),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
modifier = Modifier.size(64.dp),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
PurrfectMarqueeText(
text = name,
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
)
}
}
}
@Composable override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
val composeContext = LocalContext.current
@@ -1144,7 +1246,7 @@ object LegacyTheme : ThemeContract {
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
@@ -1225,12 +1327,10 @@ object LegacyTheme : ThemeContract {
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(Unit) {
fetchActiveTasks(this)
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
fetchActiveTasks(scope)
while (true) {
fetchActiveTasks(this)
fetchNewRecentTasks()
delay(1000)
}
}
@@ -1301,7 +1401,7 @@ object LegacyTheme : ThemeContract {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
Surface(
onClick = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -1312,9 +1412,19 @@ object LegacyTheme : ThemeContract {
.map { it.first to it.second!! }
)
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 12.sp)
}
}
}
}
Surface(
@@ -1380,13 +1490,6 @@ object LegacyTheme : ThemeContract {
TaskCard(modifier = Modifier.fillMaxWidth(), task)
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { listState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
}
}
@@ -1407,7 +1510,6 @@ object LegacyTheme : ThemeContract {
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
tasksTranslation = translation,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false

View File

@@ -0,0 +1,60 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import me.eternal.purrfectsnap.RemoteSideContext
/**
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
* Provides more nuanced feedback than standard Compose haptics.
*/
object AphelionHaptics {
/**
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
*/
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
// Fallback for older APIs
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}.onFailure { it.printStackTrace() }
}
/**
* Triggers a "soft" impact feel, good for glass interactions.
*/
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}.onFailure { it.printStackTrace() }
}
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
}
}

View File

@@ -0,0 +1,223 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.graphics.BitmapShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlin.math.sqrt
import androidx.compose.ui.platform.LocalHapticFeedback
import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
private const val BLUR_ZONE_PX = 120f
private const val BLUR_RADIUS = 30f
private const val FADE_ZONE_PX = 80f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
onComplete: () -> Unit
) {
// Safety check: if bitmap was recycled or is null, skip.
val bitmap = request.oldThemeBitmap ?: run {
LaunchedEffect(request.id) { onComplete() }
return
}
if (bitmap.isRecycled) {
LaunchedEffect(request.id) { onComplete() }
return
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
// Ensure the reveal is cleared even if navigation happens mid-animation
DisposableEffect(request.id) {
onDispose { onComplete() }
}
val maxRadius = remember(configuration) {
with(density) {
val w = configuration.screenWidthDp.dp.toPx()
val h = configuration.screenHeightDp.dp.toPx()
sqrt(w * w + h * h)
}
}
val animatedRadius = remember(request.id) { Animatable(0f) }
LaunchedEffect(request.id) {
AphelionHaptics.themeRevealTick(context, hapticFeedback)
animatedRadius.animateTo(
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
)
onComplete()
}
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
val timeValue by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 10f,
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
label = "wave_time_value"
)
val runtimeShader = remember(bitmap) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
} else null
}
val shaderPaint = remember(runtimeShader, bitmap) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = animatedRadius.value
val center = request.originCenter
drawIntoCanvas { canvas ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
} else {
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
}
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun drawWithBlurReveal(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
}
val renderNode = android.graphics.RenderNode("blurRing").apply {
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
}
val nodeCanvas = renderNode.beginRecording()
nodeCanvas.save()
nodeCanvas.clipPath(ringPath)
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
nodeCanvas.restore()
renderNode.endRecording()
canvas.drawRenderNode(renderNode)
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
canvas.restore()
}
private fun drawWithClipFade(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
}
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
canvas.restore()
}

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
/**
* Carries all data needed to execute one theme reveal transition.
*/
data class ThemeRevealRequest(
val newThemeId: String,
val originCenter: Offset,
val oldThemeBitmap: android.graphics.Bitmap?,
val id: Long = System.currentTimeMillis()
)
/**
* Observable state that lives on the [Navigation] instance.
* Optimized for stability during rapid toggle events.
*/
class ThemeRevealState {
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
private set
/**
* Requests a new theme reveal animation.
* Always starts a new reveal immediately, even if one is already in progress.
*/
fun requestReveal(
newThemeId: String,
originCenter: Offset,
bitmap: android.graphics.Bitmap?
) {
// Clean up the old one first to prevent memory leaks and "dead periods"
val oldBitmap = pendingReveal?.oldThemeBitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
// Immediately update with the new request ID to force a fresh animation
pendingReveal = ThemeRevealRequest(
newThemeId = newThemeId,
originCenter = originCenter,
oldThemeBitmap = bitmap,
id = System.currentTimeMillis() // Unique ID ensures fresh start
)
}
/** Called by the overlay composable once the animation has fully completed. */
fun clearReveal() {
val oldBitmap = pendingReveal?.oldThemeBitmap
pendingReveal = null
// Manual memory management for the heavy screenshot bitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
}
}

View File

@@ -0,0 +1,86 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
/**
* Finalized "Perfect Optic" AGSL Shader.
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
*/
object WaveEdgeShader {
const val AGSL = """
uniform shader content;
uniform float revealRadius;
uniform float2 revealCenter;
uniform float bandWidth;
uniform float time;
uniform float uProgress;
float hash(float2 p) {
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
}
float valueNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
u.y
);
}
half4 main(float2 pos) {
float d = distance(pos, revealCenter);
float energy = (1.0 - uProgress);
// Slower, more majestic large noise
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
float totalNoise = noiseLarge * energy;
float dist = d - (revealRadius + totalNoise);
if (dist > 0.0) {
return content.eval(pos);
}
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
float effectStart = revealRadius - dynamicBand;
if (dist < -dynamicBand) {
return half4(0.0);
}
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
// Asymmetric crest: Sharp start, long slow tail
float waveShape = pow(bandProgress, 2.0);
float2 dir = normalize(pos - revealCenter + 0.001);
// Refraction: Impactful but clear
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
float2 refractedPos = pos + dir * refractionAmt;
// Chromatic Aberration: Deep prism split
float aberration = waveShape * (35.0 * energy + 10.0);
half r = content.eval(refractedPos + dir * aberration).r;
half g = content.eval(refractedPos).g;
half b = content.eval(refractedPos - dir * aberration).b;
// Sharp highlight at the front edge
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
// Leading edge softening (minor)
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
return half4(
r + half(highlight),
g + half(highlight),
b + half(highlight),
half(alpha)
);
}
"""
}

View File

@@ -52,6 +52,16 @@ class SaveFolderScreen : SetupScreen() {
var currentFolder by remember {
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
}
val readablePath = remember(currentFolder) {
if (currentFolder.isBlank()) null
else runCatching {
val uri = android.net.Uri.parse(currentFolder)
val path = uri.path ?: return@runCatching currentFolder
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else currentFolder
}.getOrElse { currentFolder }
}
var showNoPickerDialog by remember { mutableStateOf(false) }
SetupCard {
StepTitle(
@@ -104,7 +114,7 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary
)
Text(
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,

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.4.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("281").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.5").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("285").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,18 @@
## v1.4.5
- New: Spoof Snap Score Locally(tq to RSR)
- New: Video Recording Timer(tq to RSR)
- New: Auto Skip(tq to RSR)
- Fix: Processing failed to save snaps(tq to RSR)
- Fix: Story counter & story source for newer versions of snapchat(tq to RSR)
- Fix: Features page using Aphelion scroll even when Aphelion is not in use(tq to Kaladin)
- Fix: Aphelion Task page not showing the thumbnails of the completed tasks(tq to Kaladin)
- Fix: Tasks page not showing the merge button when Aphelion is not in use(tq to Kaladin)
- Fix: Features and its sub pages scroll state is remembered infintely, causing the pages to load where they were exited even after the app is closed(tq to Kaladin)
- New: Expanded Friend Mutation Observer to include the new layout when Aphelion is in use(tq to Kaladin)
- New: Theme Transition Animation(tq to Kaladin)
- Fix: Friend feed menu button overlapping for group chats
- Fix: Duplicate opera download & mark snaps as seen buttons
## v1.4.1
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
- Fix: Story Counter/Story Source position(tq to AhmedRaza)

View File

@@ -1108,6 +1108,10 @@
"name": "مؤشر مصدر القصة",
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
},
"story_snap_jump": {
"name": "تخطي تلقائي",
"description": "يضيف زر تخطي للانتقال إلى أي سناب في القصة. اضغط على أيقونة التخطي أو العداد لفتح نافذة القفز"
},
"old_bitmoji_selfie": {
"name": "سيلفي Bitmoji القديم",
"description": "يعيد سيلفي Bitmoji من إصدارات Snapchat القديمة"
@@ -1155,6 +1159,16 @@
"settings_menu": {
"name": "قائمة الإعدادات",
"description": "اختر بين تخطيطات قائمة الإعدادات الجديدة والقديمة"
},
"spoof_snap_score": {
"name": "تزييف نقاط سناب شات",
"description": "يقوم بتزييف عدد نقاط سناب شات (المحلية فقط)",
"properties": {
"custom_snap_score": {
"name": "النقاط المخصصة",
"description": "تعيين نقاط السناب شات الوهمية (أقصى عدد هو 9,999,999)"
}
}
}
}
},
@@ -1924,6 +1938,10 @@
"hevc_recording": {
"name": "تسجيل HEVC",
"description": "يستخدم ترميز HEVC (H.265) لتسجيل الفيديو"
},
"video_record_timer": {
"name": "مؤقت تسجيل الفيديو",
"description": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
}
}
},

View File

@@ -336,6 +336,7 @@
"summary_active": "{active} active \u00b7 {recent} recent",
"summary_idle": "Idle \u00b7 {recent} recent",
"running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"clear_button_description": "Clear tasks",
"failed_to_open_file": "Failed to open file",
"merge_files_toast": "Merging {count} files",
@@ -361,7 +362,7 @@
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
"subtitle": "Explore and manage premium features"
},
"bypass_status": {
"active": "PurrAura Active",
@@ -391,7 +392,7 @@
"friends_empty_title": "No friends added yet",
"groups_empty_title": "No groups synced yet",
"streaks_expiration_short": "{hours}h",
"social_tagline": "Manage scopes, streaks, and previews",
"social_tagline": "Manage friends, groups, and streaks",
"social_empty_hint": "Tap the + button to sync friends or groups.",
"messaging_preview": {
"bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background",
@@ -1149,6 +1150,10 @@
"name": "Story Source Indicator",
"description": "Shows an icon indicating whether the snap was taken from the camera or uploaded from the gallery\nOnly works with friend stories"
},
"story_snap_jump": {
"name": "Auto Skip",
"description": "Adds a skip button to jump to any snap in a story. Tap the skip icon or counter to open the jump dialog"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"description": "Brings back the Bitmoji selfies from older Snapchat versions"
@@ -1196,6 +1201,16 @@
"settings_menu": {
"name": "Settings Menu",
"description": "Choose between the new and legacy settings menu layouts"
},
"spoof_snap_score": {
"name": "Spoof Snap Score",
"description": "Spoof your Snap Score (local only)",
"properties": {
"custom_snap_score": {
"name": "Custom Snap Score",
"description": "The custom Snap Score you want to display (max 9,999,999)"
}
}
}
}
},
@@ -1965,6 +1980,10 @@
"hevc_recording": {
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
},
"video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
}
}
},

View File

@@ -57,6 +57,7 @@ class Camera : ConfigContainer() {
val startupDefaultCamera = unique("startup_default_camera", "front", "back") { requireRestart() }
val overrideFrontResolution get() = _overrideFrontResolution
val overrideBackResolution get() = _overrideBackResolution
val videoRecordTimer = boolean("video_record_timer")
val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } }
}

View File

@@ -52,6 +52,7 @@ class UserInterfaceTweaks : ConfigContainer() {
val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() }
val storyCounter = boolean("story_counter") { requireRestart() }
val storySourceIndicator = boolean("story_source_indicator") { requireRestart() }
val storySnapJump = boolean("story_snap_jump") { requireRestart() }
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
@@ -62,4 +63,16 @@ class UserInterfaceTweaks : ConfigContainer() {
}
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
val customSnapScore = string("custom_snap_score") {
requireRestart()
inputCheck = { input ->
if (input.isEmpty()) true
else input.replace(Regex("[^0-9]"), "").isNotEmpty()
}
}
}
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
}

View File

@@ -0,0 +1,22 @@
package me.eternal.purrfectsnap.common.ui
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
/**
* Shared palette for PurrfectSnap overlay UI (dialogs, story overlays) shown inside Snapchat.
* Matches the PurrfectSnap manager app's premium look. Used by core module.
*/
object PurrfectOverlayPalette {
val glowPrimary = Color(0xFF8C7BFF)
val glowSecondary = Color(0xFF5FD8FF)
val textPrimary = Color.White
val textSecondary = Color(0xFFD9D3FF)
val cardOverlayColor = Color(0xFF2A2452).copy(alpha = 0.95f)
val cardOverlay = Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.95f),
Color(0xFF1A143A).copy(alpha = 0.92f)
)
)
}

View File

@@ -0,0 +1,121 @@
package me.eternal.purrfectsnap.common.ui.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@Composable
fun AphelionFriendMutationToast(
icon: ImageVector,
text: String,
bitmojiUrl: String?,
onDismiss: () -> Unit
) {
var visible by remember { mutableStateOf(false) }
var bitmojiBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(bitmojiUrl) {
if (bitmojiUrl != null) {
runCatching {
me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, _ ->
bitmojiBitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
}
}
}
}
LaunchedEffect(Unit) {
visible = true
delay(5000)
visible = false
delay(500)
onDismiss()
}
val progress by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow),
label = "progress"
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp),
contentAlignment = Alignment.TopCenter
) {
Box(
modifier = Modifier.graphicsLayer {
translationY = -100f * (1f - progress)
alpha = progress
scaleX = 0.9f + (0.1f * progress)
scaleY = 0.9f + (0.1f * progress)
}
) {
Surface(
modifier = Modifier
.widthIn(max = 340.dp)
.shadow(20.dp, RoundedCornerShape(28.dp)),
shape = RoundedCornerShape(28.dp),
color = Color(0xE61B152E),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.15f))
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(42.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
if (bitmojiBitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmojiBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
Text(
text = text,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
lineHeight = 18.sp,
modifier = Modifier.weight(1f)
)
}
}
}
}
}

View File

@@ -34,6 +34,8 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.androidx.documentfile)
implementation(libs.rhino)
implementation(libs.androidx.constraintlayout)
implementation(project(":common"))
implementation(project(":mapper"))
@@ -47,6 +49,8 @@ dependencies {
implementation(libs.androidx.material.ripple)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.material3)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.hiddenapibypass)
implementation(libs.colorpicker.compose)
}

View File

@@ -5,8 +5,10 @@ import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Build
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.runtime.Composable
import java.lang.reflect.Method
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -32,12 +34,14 @@ import me.eternal.purrfectsnap.core.data.SnapClassCache
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.eternal.purrfectsnap.core.ui.InAppOverlay
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.util.LSPatchUpdater
import me.eternal.purrfectsnap.core.util.hook.HookAdapter
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.mapper.impl.PlatformClientAttestationMapper
import me.eternal.purrfectsnap.common.ui.components.AphelionFriendMutationToast
import kotlin.reflect.KClass
import kotlin.system.exitProcess
import kotlin.system.measureTimeMillis
@@ -216,6 +220,26 @@ class PurrfectSnap {
log.verbose("Initializing features...")
runCatching {
features.init()
// Wire up the premium friend mutation toast provider
features.get(me.eternal.purrfectsnap.core.features.impl.FriendMutationObserver::class)?.let { observer ->
observer.aphelionToastProvider = { icon, text, bitmojiUrl, onDismiss ->
lateinit var composable: CustomComposable
composable = @Composable {
AphelionFriendMutationToast(
icon = icon,
text = text,
bitmojiUrl = bitmojiUrl,
onDismiss = {
inAppOverlay.removeCustomComposable(composable)
onDismiss()
}
)
}
inAppOverlay.addCustomComposable(composable)
}
}
log.verbose("Features initialized successfully")
}.onFailure { throwable ->
log.error("Failed to initialize features", throwable)

View File

@@ -154,12 +154,14 @@ class FeatureManager(
AutoDeleteSentMessages(),
FriendNotes(),
DoubleTapChatAction(),
VideoRecordTimer(),
SnapScoreChanges(),
DisableSnapModeRestrictions(),
MessageTranslator(),
PreventForcedKeyboard(),
CustomTheming(),
HideTypingIndicator(),
FakeSnapScore(),
)
features.values.toList().forEach { feature ->

View File

@@ -4,13 +4,13 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.gson.JsonObject
import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.EvictingMap
import java.io.InputStreamReader
import java.util.Calendar
@@ -23,25 +23,28 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
private val channelId by lazy {
"friend_mutation_observer".also {
notificationManager.createNotificationChannel(
NotificationChannel(
it,
translation["notification_channel_name"],
NotificationManager.IMPORTANCE_HIGH
)
NotificationChannel(it, translation["notification_channel_name"], NotificationManager.IMPORTANCE_HIGH)
)
}
}
fun getFriendAddSource(userId: String): String? {
return addSourceCache[userId]
}
// Injected from app layer — keeps core free of ui.* imports
var aphelionToastProvider: ((
icon: ImageVector,
text: String,
bitmojiUrl: String?,
onDismiss: () -> Unit
) -> Unit)? = null
fun getFriendAddSource(userId: String): String? = addSourceCache[userId]
private fun sendMutationNotification(icon: ImageVector, contentText: String, friendInfo: FriendInfo? = null) {
val currentTheme = context.config.global.uiSettings.managerTheme.get()
val isAphelion = currentTheme == "APHELION"
private fun sendWarnNotification(
contentText: String
) {
notificationManager.notify(System.nanoTime().toInt(),
Notification.Builder(context.androidContext, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(translation["notification_channel_name"])
.setContentText(contentText)
.setShowWhen(true)
@@ -49,11 +52,21 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
.build()
)
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
contentText,
durationMs = 7000
)
val provider = aphelionToastProvider
if (isAphelion && provider != null) {
val bitmojiUrl = friendInfo?.let {
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.getBitmojiSelfie(
it.bitmojiSelfieId,
it.bitmojiAvatarId,
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
)
}
provider(icon, contentText, bitmojiUrl) {
// onDismiss handled inside the provider lambda in app layer
}
} else {
context.inAppOverlay.showStatusToast(icon, contentText, durationMs = 7000)
}
}
private fun formatUsername(friendInfo: FriendInfo): String {
@@ -65,44 +78,34 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
private fun prettyPrintBirthday(month: Int, day: Int): String {
val calendar = Calendar.getInstance()
calendar[Calendar.MONTH] = month
return calendar.getDisplayName(
Calendar.MONTH,
Calendar.LONG,
context.translation.loadedLocale
)?.toString() + " " + day
return calendar.getDisplayName(Calendar.MONTH, Calendar.LONG, context.translation.loadedLocale)?.toString() + " " + day
}
override fun init() {
val config by context.config.messaging.friendMutationNotifier
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!event.url.contains("ami/friends")) return@subscribe
event.onSuccess { buffer ->
runCatching {
val jsonObject = context.gson.fromJson(InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), JsonObject::class.java)
jsonObject.getAsJsonArray("added_friends").map { it.asJsonObject }.forEach { friend ->
jsonObject.getAsJsonArray("added_friends")?.map { it.asJsonObject }?.forEach { friend ->
val userId = friend.get("user_id").asString
(friend.get("add_source")?.asString?.takeIf {
it.isNotBlank()
} ?: friend.get("add_source_type")?.asString?.takeIf {
it.isNotBlank()
})?.let {
(friend.get("add_source")?.asString?.takeIf { it.isNotBlank() }
?: friend.get("add_source_type")?.asString?.takeIf { it.isNotBlank() })?.let {
addSourceCache[userId] = it
}
}
if (config.isEmpty()) return@runCatching
jsonObject.getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend ->
jsonObject.getAsJsonArray("friends")?.map { it.asJsonObject }?.forEach { friend ->
runCatching {
val userId = friend.get("user_id")?.asString
val userId = friend.get("user_id")?.asString ?: return@forEach
if (userId == context.database.myUserId) return@forEach
val databaseFriend = context.database.getFriendInfo(userId ?: return@forEach) ?: return@forEach
val databaseFriend = context.database.getFriendInfo(userId) ?: return@forEach
if (FriendLinkType.fromValue(databaseFriend.friendLinkType) != FriendLinkType.MUTUAL) return@forEach
if (config.contains("remove_friend") && friend.get("direction")?.asString == "OUTGOING" && !friend.has("fidelius_info")) {
sendWarnNotification(translation.format("friend_removed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.PersonRemove, translation.format("friend_removed", "username" to formatUsername(databaseFriend)), databaseFriend)
return@forEach
}
@@ -111,47 +114,38 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
((it shr 32).toInt()).toString().padStart(2, '0') + "-" + (it.toInt()).toString().padStart(2, '0')
} != friend.get("birthday")?.asString
) {
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let {
prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt())
}
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let { prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt()) }
if (!friend.has("birthday")) {
sendWarnNotification(translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()), databaseFriend)
} else {
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let {
prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt())
}
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let { prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt()) }
if (oldBirthday == null) {
sendWarnNotification(translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()), databaseFriend)
} else {
sendWarnNotification(translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()))
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()), databaseFriend)
}
}
}
if (config.contains("bitmoji_avatar_changes") && databaseFriend.bitmojiAvatarId != friend.get("bitmoji_avatar_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_selfie_changes") && databaseFriend.bitmojiSelfieId != friend.get("bitmoji_selfie_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_background_changes") && databaseFriend.bitmojiBackgroundId != friend.get("bitmoji_background_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Image, translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
if (config.contains("bitmoji_scene_changes") && databaseFriend.bitmojiSceneId != friend.get("bitmoji_scene_id")?.asString) {
sendWarnNotification(translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)))
sendMutationNotification(Icons.Default.Landscape, translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
}
}.onFailure {
context.log.error("Failed to process friend", it)
}
}.onFailure { context.log.error("Failed to process friend", it) }
}
}.onFailure {
context.log.error("Failed to process friends", it)
}
}.onFailure { context.log.error("Failed to process friends", it) }
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.media.AudioRecord
import android.media.MediaCodec
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
class VideoRecordTimer : Feature("Video Record Timer") {
override fun init() {
if (!context.config.camera.videoRecordTimer.get()) return
val activeComponents = mutableSetOf<Int>()
fun startRecording(componentHashCode: Int) {
synchronized(activeComponents) {
if (activeComponents.isEmpty()) {
context.inAppOverlay.videoRecordTimerState.isRecording = true
context.inAppOverlay.videoRecordTimerState.recordingStartTime = System.currentTimeMillis() - 1000
}
activeComponents.add(componentHashCode)
}
}
fun stopRecording(componentHashCode: Int) {
synchronized(activeComponents) {
activeComponents.remove(componentHashCode)
if (activeComponents.isEmpty()) {
context.inAppOverlay.videoRecordTimerState.isRecording = false
}
}
}
AudioRecord::class.java.hook("startRecording", HookStage.AFTER) {
startRecording(it.thisObject<AudioRecord>().hashCode())
}
AudioRecord::class.java.hook("stop", HookStage.BEFORE) {
stopRecording(it.thisObject<AudioRecord>().hashCode())
}
AudioRecord::class.java.hook("release", HookStage.BEFORE) {
stopRecording(it.thisObject<AudioRecord>().hashCode())
}
MediaCodec::class.java.hook("start", HookStage.AFTER) {
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
startRecording(it.thisObject<MediaCodec>().hashCode())
}
}
MediaCodec::class.java.hook("stop", HookStage.BEFORE) {
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
stopRecording(it.thisObject<MediaCodec>().hashCode())
}
}
MediaCodec::class.java.hook("release", HookStage.BEFORE) {
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
stopRecording(it.thisObject<MediaCodec>().hashCode())
}
}
}
}

View File

@@ -0,0 +1,116 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.widget.TextView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.ui.getValdiViewNode
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiViewNode
class FakeSnapScore : Feature("Fake Snap Score") {
private fun findAllSnapTextViewsRecursive(node: ValdiViewNode, depth: Int = 0, result: MutableList<ValdiViewNode> = mutableListOf()): List<ValdiViewNode> {
if (depth > 15) return result
if (node.getClassName().endsWith("SnapTextView")) result.add(node)
for (child in node.getChildren()) {
findAllSnapTextViewsRecursive(child, depth + 1, result)
}
return result
}
override fun init() {
if (context.config.userInterface.spoofSnapScore.globalState != true) return
val customScoreRaw = context.config.userInterface.spoofSnapScore.customSnapScore.getNullable()?.trim()?.takeIf { it.isNotBlank() }
?: return
val customScore = try {
val digitsOnly = customScoreRaw.replace(Regex("[^0-9]"), "")
if (digitsOnly.isNotEmpty()) {
val clampedVal = digitsOnly.toLong().coerceAtMost(9999999L)
val formatted = StringBuilder()
val reversed = clampedVal.toString().reversed()
for (i in reversed.indices) {
formatted.append(reversed[i])
if ((i + 1) % 3 == 0 && i != reversed.lastIndex) {
formatted.append(",")
}
}
formatted.reverse().toString()
} else {
null
}
} catch (e: Exception) {
null
} ?: return
// Approach 1: AddViewEvent + Valdi setAttribute (score dialog when tapping pill)
context.event.subscribe(AddViewEvent::class) { event ->
if (event.viewClassName.endsWith("ProfileFlatlandmySnapScoreIdentityPillDialogView")) {
event.view.post {
event.view.getValdiContext()?.enqueueNextRenderCallback {
val rootNode = event.view.getValdiViewNode() ?: return@enqueueNextRenderCallback
val snapTextViews = findAllSnapTextViewsRecursive(rootNode)
// Only spoof the blue pill (2nd), white shows original score
snapTextViews.getOrNull(1)?.setAttribute("value", customScore)
event.view.postInvalidate()
}
event.view.postInvalidate()
}
}
}
// Approach 2: TextView.setText hook as fallback - text change only, no layout modifications
onNextActivityCreate {
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
val text = param.argNullable<CharSequence>(0)?.toString() ?: return@hook
if (!text.matches(Regex("^[0-9\\s,.]+$"))) return@hook
val digits = text.replace(Regex("[^0-9]"), "")
if (digits.length < 4 && !text.contains(",")) return@hook
val textView = param.thisObject() as TextView
var parent = textView.parent
var isMyProfile = false
var isFriendContext = false
var isInScoreDialog = false
while (parent != null) {
val fullName = parent.javaClass.name.lowercase()
if (fullName.contains("friendsnapscore") || fullName.contains("friendprofile")) {
isFriendContext = true
break
}
if (fullName.contains("mysnapscore") || fullName.contains("myprofile")) {
isMyProfile = true
}
if (fullName.contains("mysnapscoreidentitypilldialog")) isInScoreDialog = true
parent = parent.parent
}
// Only spoof blue pill in profile (not in dialog - AddViewEvent handles that). White always shows original.
if (isMyProfile && !isFriendContext && !isInScoreDialog) {
param.setArg(0, customScore)
// Prevent ellipsis (...) when score is large - fix TextView and parent TextViews (white + blue pill)
textView.post {
val minW = textView.paint.measureText(customScore).toInt() + 80
var current: android.view.View? = textView
for (i in 0..4) {
if (current == null) break
if (current is TextView) {
current.ellipsize = null
current.maxWidth = Int.MAX_VALUE
current.minWidth = minW
current.minimumWidth = minW
}
current = current.parent as? android.view.View
current?.requestLayout()
}
}
}
}
}
}
}

View File

@@ -41,8 +41,12 @@ class OperaStoryCounter : Feature("OperaStoryCounter") {
override fun init() {
val showCounter = this@OperaStoryCounter.context.config.userInterface.storyCounter.get()
val showSourceIndicator = this@OperaStoryCounter.context.config.userInterface.storySourceIndicator.get()
val storySnapJump = this@OperaStoryCounter.context.config.userInterface.storySnapJump.get()
val storySnapListDownload = this@OperaStoryCounter.context.config.downloader.storySnapListDownload.get()
val operaDownloadButton = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get()
if (!showCounter && !showSourceIndicator) return
// OperaStoryOverlay handles counter/source/jump when any of these are enabled
if (showCounter || showSourceIndicator || storySnapJump || storySnapListDownload || operaDownloadButton) return
this@OperaStoryCounter.context.event.subscribe(AddViewEvent::class) { event ->
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {

View File

@@ -1,16 +1,49 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.ui.children
import java.lang.ref.WeakReference
/**
* Provides snap jump logic for Story Snap List Download batch downloads.
* Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps.
* Main overlay feature for story counter, source indicator, and Auto Skip (snap jump).
* Also provides snap jump logic for Story Snap List Download batch downloads.
*/
class OperaStoryOverlay : Feature("OperaStoryOverlay") {
private val overlayState = OperaStoryOverlayState()
@@ -18,36 +51,173 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
private lateinit var snapJump: OperaStorySnapJump
override fun init() {
val showCounter = context.config.userInterface.storyCounter.get()
val showSourceIndicator = context.config.userInterface.storySourceIndicator.get()
val enableSnapJump = context.config.userInterface.storySnapJump.get()
val storySnapListDownload = context.config.downloader.storySnapListDownload.get()
val showDownloadButton = context.config.downloader.operaDownloadButton.get()
if (!storySnapListDownload) return
if (!showCounter && !showSourceIndicator && !enableSnapJump && !storySnapListDownload && !showDownloadButton) return
snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() }
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as FrameLayout
if (event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as? ViewGroup ?: return@subscribe
val isWrapped = viewGroup is FrameLayout && viewGroup.childCount == 1 && viewGroup.getChildAt(0) is ViewGroup
val actualLayer = if (isWrapped) viewGroup.getChildAt(0) as ViewGroup else viewGroup
if (viewGroup.findViewWithTag<View>("story_counter") != null ||
event.parent.findViewWithTag<View>("story_counter") != null) return@subscribe
event.parent.findViewWithTag<View>("story_counter") != null ||
actualLayer.javaClass.name.endsWith("ScalableCircleMaskFrameLayout")
) return@subscribe
if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe
if (actualLayer.childCount > 0 && !actualLayer.javaClass.name.contains("OperaShapeView")) {
storyFrameLayout = WeakReference(viewGroup as FrameLayout)
viewGroup.tag = "story_counter"
storyFrameLayout = WeakReference(viewGroup)
val composeView = createComposeView(viewGroup.context) {
val counterText = overlayState.counterState.value
val source = overlayState.sourceState.value
val hasCounter = showCounter && counterText.isNotEmpty()
val hasSource = showSourceIndicator && source.isNotEmpty()
val currentIdx = overlayState.currentIndexState.intValue
val totalCount = overlayState.totalCountState.intValue
var showJumpDialog by remember { mutableStateOf(false) }
val isDownloadButtonEnabled = context.config.downloader.operaDownloadButton.get()
if (hasCounter || hasSource || enableSnapJump || isDownloadButtonEnabled) {
Column(
horizontalAlignment = Alignment.End
) {
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
Box(
modifier = Modifier
.background(
color = Color(0x4C000000),
shape = CircleShape
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
) {
if (hasCounter) {
OperaStoryCounterDisplay(
counterText = counterText,
enableSnapJump = enableSnapJump,
totalCount = totalCount,
onCounterClick = { showJumpDialog = true }
)
}
if (enableSnapJump && (hasCounter || totalCount > 1) && totalCount > 1) {
Box(
modifier = Modifier
.width(1.dp)
.height(10.dp)
.background(Color.White.copy(alpha = 0.4f))
)
Icon(
imageVector = Icons.Outlined.SkipNext,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(14.dp)
.clickable { showJumpDialog = true }
)
}
if (hasSource) {
if (hasCounter || (enableSnapJump && totalCount > 1)) {
Box(
modifier = Modifier
.width(1.dp)
.height(10.dp)
.background(Color.White.copy(alpha = 0.4f))
)
}
OperaStorySourceIndicatorDisplay(source = source)
}
}
}
}
if (isDownloadButtonEnabled) {
val mediaDownloader = remember { context.feature(MediaDownloader::class) }
val snapSource = overlayState.snapSourceState.value
val isInConversation = overlayState.isInConversationState.value
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY" && !isInConversation) {
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
Spacer(modifier = Modifier.height(6.dp))
}
Box(
modifier = Modifier
.background(
color = Color(0x4C000000),
shape = CircleShape
)
) {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.padding(6.dp)
.size(18.dp)
.clickable {
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false)
}
)
}
}
}
if (enableSnapJump && showJumpDialog && totalCount > 1) {
OperaStorySnapJumpDialog(
currentIndex = currentIdx,
totalCount = totalCount,
onDismiss = { showJumpDialog = false },
onJump = { targetIndex ->
showJumpDialog = false
snapJump.jumpToSnap(targetIndex)
}
)
}
}
}
}
composeView.tag = "story_counter"
composeView.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP or Gravity.END
topMargin = this@OperaStoryOverlay.context.userInterface.dpToPx(50)
marginEnd = this@OperaStoryOverlay.context.userInterface.dpToPx(10)
}
viewGroup.addView(composeView)
}
}
}
onNextActivityCreate {
overlayState.setupDisplayStateHook(
context = context,
showCounter = false,
showSourceIndicator = false,
onSnapFullyDisplayed = {
if (snapJump.isJumping()) {
snapJump.onSnapFullyDisplayed(it)
showCounter = showCounter,
showSourceIndicator = showSourceIndicator,
onSnapFullyDisplayed = if (enableSnapJump) {
{ currentIndex ->
if (snapJump.isJumping()) {
snapJump.onSnapFullyDisplayed(currentIndex)
}
}
},
onClearState = { snapJump.removeJumpOverlay() }
} else null,
onClearState = if (enableSnapJump) { { snapJump.removeJumpOverlay() } } else null
)
}
}
@@ -55,3 +225,46 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean =
snapJump.requestJumpToSnap(targetIndex, totalCountOverride)
}
@Composable
private fun OperaStoryCounterDisplay(
counterText: String,
enableSnapJump: Boolean,
totalCount: Int,
onCounterClick: () -> Unit,
modifier: Modifier = Modifier
) {
if (counterText.isEmpty()) return
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.then(
if (enableSnapJump && totalCount > 1)
Modifier.clickable { onCounterClick() }
else Modifier
)
) {
Text(
text = counterText,
color = Color.White,
fontSize = 10.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
}
}
@Composable
private fun OperaStorySourceIndicatorDisplay(
source: String,
modifier: Modifier = Modifier
) {
if (source.isEmpty()) return
val icon = if (source == "CAMERA") Icons.Outlined.CameraAlt else Icons.Outlined.PhotoLibrary
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = modifier.size(11.dp)
)
}

View File

@@ -20,6 +20,8 @@ class OperaStoryOverlayState {
val sourceState = mutableStateOf("")
val currentIndexState = mutableIntStateOf(-1)
val totalCountState = mutableIntStateOf(0)
val snapSourceState = mutableStateOf<String?>(null)
val isInConversationState = mutableStateOf(false)
fun setupDisplayStateHook(
context: ModContext,
@@ -44,23 +46,14 @@ class OperaStoryOverlayState {
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString()
if (mediaParamMap.containsKey("MESSAGE_ID")) {
context.runOnUiThread {
counterState.value = ""
sourceState.value = ""
currentIndexState.intValue = -1
totalCountState.intValue = 0
onClearState?.invoke()
}
return@hook
}
if (snapSource == "SINGLE_SNAP_STORY") {
if (mediaParamMap.containsKey("MESSAGE_ID") || snapSource == "SINGLE_SNAP_STORY") {
context.runOnUiThread {
counterState.value = ""
sourceState.value = ""
currentIndexState.intValue = -1
totalCountState.intValue = 0
snapSourceState.value = snapSource
isInConversationState.value = mediaParamMap.containsKey("MESSAGE_ID")
onClearState?.invoke()
}
return@hook
@@ -86,6 +79,8 @@ class OperaStoryOverlayState {
sourceState.value = mediaOrigin
currentIndexState.intValue = currentIndex ?: -1
totalCountState.intValue = totalCount ?: 0
snapSourceState.value = snapSource
isInConversationState.value = false
onSnapFullyDisplayed?.let { callback ->
if (currentIndex != null) callback(currentIndex)

View File

@@ -0,0 +1,143 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import me.eternal.purrfectsnap.common.ui.PurrfectOverlayPalette
import kotlin.math.roundToInt
/**
* Composable dialog for jumping to a specific snap in the story (Auto Skip).
* Matches SnapEnhance dialog size (75% width), transparency (0.88), and layout.
* Styled with PurrfectSnap colors.
*/
@Composable
fun OperaStorySnapJumpDialog(
currentIndex: Int,
totalCount: Int,
onDismiss: () -> Unit,
onJump: (Int) -> Unit
) {
var sliderValue by remember { mutableFloatStateOf((currentIndex + 1).toFloat()) }
val selectedSnap = sliderValue.roundToInt()
Dialog(onDismissRequest = onDismiss) {
Box(
modifier = Modifier
.fillMaxWidth(0.75f)
.background(
color = PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.88f),
shape = RoundedCornerShape(24.dp)
)
.padding(20.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "$selectedSnap",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = PurrfectOverlayPalette.textPrimary
)
Text(
text = " / $totalCount",
fontSize = 14.sp,
color = PurrfectOverlayPalette.textSecondary,
modifier = Modifier.padding(bottom = 5.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Slider(
value = sliderValue,
onValueChange = { sliderValue = it },
valueRange = 1f..totalCount.toFloat(),
steps = if (totalCount > 2) totalCount - 2 else 0,
colors = SliderDefaults.colors(
thumbColor = PurrfectOverlayPalette.glowPrimary,
activeTrackColor = PurrfectOverlayPalette.glowPrimary,
activeTickColor = Color.Transparent,
inactiveTrackColor = PurrfectOverlayPalette.textPrimary.copy(alpha = 0.12f),
inactiveTickColor = Color.Transparent
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(14.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.weight(1f)
.background(
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(12.dp)
)
.clickable { onDismiss() }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Cancel",
fontSize = 13.sp,
color = PurrfectOverlayPalette.textSecondary
)
}
Box(
modifier = Modifier
.weight(1f)
.background(
color = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f),
shape = RoundedCornerShape(12.dp)
)
.clickable {
onDismiss()
onJump(selectedSnap - 1)
}
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Go",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
}
}
}

View File

@@ -63,10 +63,16 @@ class CallRecorderUIState {
var lastInteractionTime by mutableStateOf(0L)
}
class VideoRecordTimerState {
var isRecording by mutableStateOf(false)
var recordingStartTime by mutableStateOf(0L)
}
class InAppOverlay(
private val context: ModContext
) {
val callRecorderState = CallRecorderUIState()
val videoRecordTimerState = VideoRecordTimerState()
companion object {
fun showCrashOverlay(content: String, throwable: Throwable? = null) {
// deny network requests
@@ -243,6 +249,79 @@ class InAppOverlay(
}
CallRecorderOverlay()
VideoRecordTimerOverlay()
}
}
@Composable
private fun VideoRecordTimerOverlay() {
var elapsedTime by remember { mutableStateOf(0L) }
LaunchedEffect(videoRecordTimerState.isRecording) {
if (videoRecordTimerState.isRecording) {
while (videoRecordTimerState.isRecording) {
delay(100)
elapsedTime = System.currentTimeMillis() - videoRecordTimerState.recordingStartTime
}
} else {
elapsedTime = 0L
}
}
AnimatedVisibility(
visible = videoRecordTimerState.isRecording,
enter = fadeIn(animationSpec = tween(300)) + scaleIn(
initialScale = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
targetScale = 0.8f,
animationSpec = tween(200)
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 45.dp),
contentAlignment = Alignment.TopCenter
) {
val seconds = (elapsedTime / 1000) % 60
val minutes = (elapsedTime / 1000) / 60
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseRatio by infiniteTransition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulseRatio"
)
Row(
modifier = Modifier
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(10.dp)
.background(Color.Red.copy(alpha = pulseRatio), CircleShape)
)
Text(
text = String.format("%02d:%02d", minutes, seconds),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp
)
}
}
}
}

View File

@@ -547,6 +547,25 @@ class FriendFeedInfoMenu : AbstractMenu() {
}
(event.view as? ViewGroup)?.addView(actionSheetItemsContainerLayout, 0)
actionSheetItemsContainerLayout.post {
val parentViewGroup = actionSheetItemsContainerLayout.parent as? ViewGroup ?: return@post
val topOffset = parentViewGroup.children()
.filter { it !== actionSheetItemsContainerLayout && it.visibility != View.GONE }
.maxOfOrNull { child ->
child.bottom.takeIf { it > 0 } ?: child.measuredHeight
} ?: 0
val desiredPadding = topOffset + this@FriendFeedInfoMenu.context.userInterface.dpToPx(10)
if (actionSheetItemsContainerLayout.paddingTop != desiredPadding) {
actionSheetItemsContainerLayout.setPadding(
actionSheetItemsContainerLayout.paddingLeft,
desiredPadding,
actionSheetItemsContainerLayout.paddingRight,
actionSheetItemsContainerLayout.paddingBottom
)
actionSheetItemsContainerLayout.requestLayout()
}
}
}
if (event.parent is LinearLayout && event.viewClassName.endsWith("SnapCardView") && hasAvatarHeader(event.parent)) {

View File

@@ -1,13 +1,16 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.graphics.Rect
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -18,10 +21,14 @@ import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -45,17 +52,20 @@ import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import java.util.concurrent.atomic.AtomicInteger
class OperaViewerIcons : AbstractMenu() {
private val actionMenuIconSize by lazy { context.userInterface.dpToPx(32) }
private val actionMenuIconMargin by lazy { context.userInterface.dpToPx(5) }
private val actionMenuIconMarginTop by lazy { context.userInterface.dpToPx(10) }
private val injectedParentTag = randomTag()
private val viewerVisibleState = mutableStateOf(false)
private val viewerMessageContextState = mutableStateOf<OperaViewerMessageContext?>(null)
private val inlineDownloadButtonVisibleState = mutableStateOf(false)
private val inlineMarkButtonVisibleState = mutableStateOf(false)
private var overlayRegistered = false
private var hooksInitialized = false
private var hasSeenVisibleModernViewerContainer = false
private val modernViewerHideToken = AtomicInteger(0)
private val useModernViewerBehavior by lazy {
isSnapchatVersionAtLeast(
context.mappings.getSnapchatPackageInfo()?.versionName,
@@ -69,9 +79,10 @@ class OperaViewerIcons : AbstractMenu() {
hooksInitialized = true
registerOverlayFallback()
val mediaDownloader = context.feature(MediaDownloader::class)
context.event.subscribe(OnSnapInteractionEvent::class) {
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
refreshViewerMessageContext(mediaDownloader)
}
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
@@ -82,55 +93,138 @@ class OperaViewerIcons : AbstractMenu() {
) { param ->
val viewState = param.thisObject<Any>().getObjectField(viewStateField.get()!!).toString()
val isVisible = viewState == "FULLY_DISPLAYED"
viewerVisibleState.value = isVisible
if (!isVisible) {
viewerMessageContextState.value = null
inlineMarkButtonVisibleState.value = false
scheduleHideIfViewerActuallyClosed()
return@hook
}
viewerMessageContextState.value = context.feature(MediaDownloader::class).resolveCurrentSnapMessageContext()
modernViewerHideToken.incrementAndGet()
viewerVisibleState.value = true
refreshViewerMessageContext(mediaDownloader)
}
}
}
}
private fun refreshViewerMessageContext(
mediaDownloader: MediaDownloader,
retryCount: Int = 4
) {
context.coroutineScope.launch(Dispatchers.Main) {
repeat(retryCount) { attempt ->
mediaDownloader.resolveViewerMessageContextFromParamMap()?.let {
viewerMessageContextState.value = it
return@launch
}
if (attempt < retryCount - 1) {
delay(120L * (attempt + 1))
}
}
viewerMessageContextState.value = null
}
}
@Composable
private fun OverlayActionButton(
icon: ImageVector,
onTap: () -> Unit,
onLongPress: (() -> Unit)? = null
) {
Surface(
modifier = Modifier
.size(52.dp)
.pointerInput(onLongPress) {
detectTapGestures(
onTap = { onTap() },
onLongPress = {
onLongPress?.invoke()
}
)
},
shape = CircleShape,
color = Color.Black.copy(alpha = 0.55f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
tint = Color.White,
contentDescription = null
)
}
}
}
private fun registerOverlayFallback() {
if (overlayRegistered) return
overlayRegistered = true
context.inAppOverlay.addCustomComposable {
val mediaDownloader = context.feature(MediaDownloader::class)
val messageContext = viewerMessageContextState.value
if (
!context.config.messaging.markSnapAsSeenButton.get() ||
!viewerVisibleState.value ||
inlineMarkButtonVisibleState.value ||
messageContext == null
) return@addCustomComposable
LaunchedEffect(messageContext) {
var hiddenChecks = 0
while (viewerVisibleState.value && viewerMessageContextState.value == messageContext) {
delay(160)
if (hasVisibleModernViewerContainer()) {
hasSeenVisibleModernViewerContainer = true
hiddenChecks = 0
continue
}
if (!hasSeenVisibleModernViewerContainer) continue
hiddenChecks++
if (hiddenChecks >= 2) {
clearModernViewerState()
break
}
}
}
val showDownloadFallback = context.config.downloader.operaDownloadButton.get() &&
!inlineDownloadButtonVisibleState.value
val showMarkFallback = context.config.messaging.markSnapAsSeenButton.get() &&
!inlineMarkButtonVisibleState.value
if (!showDownloadFallback && !showMarkFallback) return@addCustomComposable
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 18.dp, bottom = 118.dp),
contentAlignment = Alignment.BottomEnd
) {
Surface(
modifier = Modifier
.size(52.dp)
.clickable {
context.coroutineScope.launch {
markCurrentSnapAsSeen(parent = null)
}
},
shape = CircleShape,
color = Color.Black.copy(alpha = 0.55f)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.End
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.RemoveRedEye,
tint = Color.White,
contentDescription = null
if (showDownloadFallback) {
OverlayActionButton(
icon = Icons.Outlined.Download,
onTap = {
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false)
},
onLongPress = {
context.androidContext.vibrateLongPress()
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = true)
}
)
}
if (showMarkFallback) {
OverlayActionButton(
icon = Icons.Default.RemoveRedEye,
onTap = {
context.coroutineScope.launch {
markCurrentSnapAsSeen(parent = null)
}
}
)
}
}
@@ -138,6 +232,52 @@ class OperaViewerIcons : AbstractMenu() {
}
}
private fun clearModernViewerState() {
modernViewerHideToken.incrementAndGet()
viewerVisibleState.value = false
viewerMessageContextState.value = null
inlineDownloadButtonVisibleState.value = false
inlineMarkButtonVisibleState.value = false
hasSeenVisibleModernViewerContainer = false
}
private fun scheduleHideIfViewerActuallyClosed() {
val token = modernViewerHideToken.incrementAndGet()
context.coroutineScope.launch(Dispatchers.Main) {
delay(240)
if (modernViewerHideToken.get() != token) return@launch
if (hasVisibleModernViewerContainer()) return@launch
clearModernViewerState()
}
}
private fun isActuallyVisible(view: View): Boolean {
val visibleRect = Rect()
return view.isShown &&
view.getGlobalVisibleRect(visibleRect) &&
visibleRect.height() > 0 &&
visibleRect.width() > 0
}
private fun hasVisibleOpenLayout(view: View): Boolean {
if (view.javaClass.hasNameSuffixInHierarchy("OpenLayout") && isActuallyVisible(view)) {
return true
}
val viewGroup = view as? ViewGroup ?: return false
for (index in 0 until viewGroup.childCount) {
if (hasVisibleOpenLayout(viewGroup.getChildAt(index))) {
return true
}
}
return false
}
private fun hasVisibleModernViewerContainer(): Boolean {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return false
return hasVisibleOpenLayout(contentView)
}
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
var current = this
while (current != null) {
@@ -152,22 +292,33 @@ class OperaViewerIcons : AbstractMenu() {
if (!event.parent.javaClass.hasNameSuffixInHierarchy("OpenLayout")) return false
val viewGroup = event.view as? ViewGroup ?: return false
if (viewGroup.getTag(injectedParentTag) != null) return false
val hasOnlyImageChildren = viewGroup.childCount > 0 && viewGroup.children().all { it is ImageView }
val hasMaskFrameSibling = event.parent.children().any {
val hasMaskFrameSibling = (event.parent as? ViewGroup)?.children()?.any {
it.javaClass.hasNameSuffixInHierarchy("ScalableCircleMaskFrameLayout")
}
} == true
return hasOnlyImageChildren || hasMaskFrameSibling
}
private fun resolveCurrentMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
return mediaDownloader.resolveCurrentSnapMessageContext()?.also {
return mediaDownloader.resolveViewerMessageContextFromParamMap()?.also {
viewerMessageContextState.value = it
}
}
private fun hasPreviewToolbar(parent: ViewGroup): Boolean {
return (parent.parent as? ViewGroup)?.children()?.any { child ->
child is ViewGroup && child.children().any { it::class.java.name.endsWith("PreviewToolbar") }
} == true
}
private fun syncInlineDownloadButtonVisibility(view: View, mediaDownloader: MediaDownloader, parent: ViewGroup) {
val isVisible = resolveCurrentMessageContext(mediaDownloader) != null && !hasPreviewToolbar(parent)
view.visibility = if (isVisible) View.VISIBLE else View.GONE
inlineDownloadButtonVisibleState.value = isVisible
}
private fun syncInlineMarkButtonVisibility(view: View, mediaDownloader: MediaDownloader) {
val isVisible = resolveCurrentMessageContext(mediaDownloader) != null
view.visibility = if (isVisible) View.VISIBLE else View.GONE
@@ -214,33 +365,23 @@ class OperaViewerIcons : AbstractMenu() {
}
override fun onViewAdded(event: AddViewEvent) {
if (!useModernViewerBehavior) {
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as? ViewGroup ?: return
if (
viewGroup.childCount == 0 ||
viewGroup.children().any { it !is ImageView } ||
event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }
) return
inject(viewGroup)
if (useModernViewerBehavior) {
if (shouldInjectIntoViewer(event)) {
modernViewerHideToken.incrementAndGet()
viewerVisibleState.value = true
refreshViewerMessageContext(context.feature(MediaDownloader::class))
}
return
}
if (!shouldInjectIntoViewer(event)) return
val viewGroup = event.view as? ViewGroup ?: return
viewGroup.setTag(injectedParentTag, true)
viewerVisibleState.value = true
viewGroup.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
viewerVisibleState.value = true
}
override fun onViewDetachedFromWindow(v: View) {
viewerVisibleState.value = false
inlineMarkButtonVisibleState.value = false
}
})
inject(viewGroup)
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as? ViewGroup ?: return
if (
viewGroup.childCount == 0 ||
viewGroup.children().any { it !is ImageView } ||
event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }
) return
inject(viewGroup)
}
}
private fun inject(parent: ViewGroup) {
@@ -259,16 +400,16 @@ class OperaViewerIcons : AbstractMenu() {
}
addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.visibility = View.VISIBLE
(parent.parent as? ViewGroup)?.children()?.forEach { child ->
if (child !is ViewGroup) return@forEach
child.children().forEach {
if (it::class.java.name.endsWith("PreviewToolbar")) v.visibility = View.GONE
}
inlineDownloadButtonVisibleState.value = false
this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) {
delay(250)
syncInlineDownloadButtonVisibility(v, mediaDownloader, parent)
}
}
override fun onViewDetachedFromWindow(v: View) {}
override fun onViewDetachedFromWindow(v: View) {
inlineDownloadButtonVisibleState.value = false
}
})
addView(createComposeView(parent.context) {

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.4.1
APP_VERSION_CODE=281
APP_VERSION_NAME=1.4.5
APP_VERSION_CODE=285
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

View File

@@ -31,14 +31,19 @@ workmanager = "2.10.4"
fetch = "3.4.1"
yukihookapi = "1.3.1"
kavaref = "1.0.2"
constraintlayout-compose = "1.1.0"
constraintlayout = "2.2.1"
[libraries]
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compose" }
accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" }
fetch = { group = "com.github.tonyofrancis.Fetch", name = "fetch2", version.ref = "fetch" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }