Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 | ||
|
|
58c4be44f2 | ||
|
|
3d83c3116c | ||
|
|
2b00898355 | ||
|
|
975a9a101f | ||
|
|
793659c63b | ||
|
|
5bdc1d7f59 | ||
|
|
fc66453167 | ||
|
|
84a9d01d6a | ||
|
|
a4b948bba3 | ||
|
|
8c720992de | ||
|
|
d1fc97cee8 | ||
|
|
9df49f1bff | ||
|
|
652062769e | ||
|
|
f1eb833655 | ||
|
|
a27753d8ae | ||
|
|
da8a261202 | ||
|
|
532ebfe0a7 | ||
|
|
bf7f371022 | ||
|
|
15c9a3fd5d | ||
|
|
bb0ab20a5b | ||
|
|
9a507684ee | ||
|
|
1fbf82b4fb | ||
|
|
53696c26f4 | ||
|
|
381b190535 | ||
|
|
50199b052f | ||
|
|
6948d86efc |
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.ThemePreferences
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
|
||||
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
|
||||
import android.content.IntentFilter
|
||||
|
||||
@@ -212,6 +213,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
navigation.NavContent(contentPadding, startDestination)
|
||||
|
||||
// Theme Reveal Overlay (Android 13+ only for stability)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
|
||||
CircularRevealOverlay(
|
||||
context = managerContext,
|
||||
request = revealRequest,
|
||||
onComplete = { navigation.themeRevealState.clearReveal() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Instantly clear reveal state on older versions
|
||||
navigation.themeRevealState.pendingReveal?.let {
|
||||
navigation.themeRevealState.clearReveal()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
|
||||
@@ -104,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.navigation
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
|
||||
import kotlin.math.round
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
@@ -122,6 +123,7 @@ class Navigation(
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
var globalScrollOffset by mutableIntStateOf(0)
|
||||
val themeRevealState = ThemeRevealState()
|
||||
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.zIndex
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
@@ -49,6 +50,10 @@ fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Floating Top Bar for Aphelion.
|
||||
* Handles the signature morphing animation and provides a "Bottom Content" slot.
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingTopBar(
|
||||
title: String,
|
||||
@@ -56,16 +61,21 @@ fun FloatingTopBar(
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollOffset: Int = 0,
|
||||
enableMorph: Boolean = false,
|
||||
containerAlpha: Float = 1f,
|
||||
titleAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val focusFactor by remember(scrollOffset) {
|
||||
derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
|
||||
val focusFactor by remember(scrollOffset, enableMorph) {
|
||||
derivedStateOf {
|
||||
if (!enableMorph) 0f
|
||||
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val morphingParams by remember(focusFactor, statusBarHeight) {
|
||||
@@ -88,10 +98,10 @@ fun FloatingTopBar(
|
||||
|
||||
var hasSnapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(focusFactor) {
|
||||
if (focusFactor >= 1f && !hasSnapped) {
|
||||
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasSnapped = true
|
||||
} else if (focusFactor < 0.9f) {
|
||||
} else if (focusFactor < 0.5f) {
|
||||
hasSnapped = false
|
||||
}
|
||||
}
|
||||
@@ -115,7 +125,7 @@ fun FloatingTopBar(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
@@ -183,87 +193,91 @@ fun FloatingTopBar(
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
|
||||
bottomContent(focusFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,283 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
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 (context.activity != null) {
|
||||
Box {
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showExportDropdownMenu = !showExportDropdownMenu
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showSearchBar && combinedSuggestions.isNotEmpty()) {
|
||||
Surface(
|
||||
@@ -1310,13 +1406,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 +1494,7 @@ class FeaturesRootSection : Routes.Route() {
|
||||
onSearchQueryChange = { liveSearchQuery = it },
|
||||
onBack = onBack,
|
||||
scrollOffset = computedScrollOffset,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it }
|
||||
onHeightMeasured = { controlsHeight = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1537,7 +1635,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 +1647,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class HomeAbout : Routes.Route() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"])
|
||||
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
|
||||
}
|
||||
|
||||
key(themeId) {
|
||||
|
||||
@@ -254,7 +254,7 @@ class HomeLogs : Routes.Route() {
|
||||
tint = PurrfectPalette.glowPrimary
|
||||
)
|
||||
},
|
||||
text = { Text(text = translation["export_button"] ?: "Export", color = Color.White) },
|
||||
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onExport()
|
||||
showMenu = false
|
||||
@@ -268,7 +268,7 @@ class HomeLogs : Routes.Route() {
|
||||
tint = Color(0xFFFF9CAB)
|
||||
)
|
||||
},
|
||||
text = { Text(text = translation["clear_button"] ?: "Clear", color = Color.White) },
|
||||
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onClear()
|
||||
showMenu = false
|
||||
|
||||
@@ -776,3 +776,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
@@ -155,7 +150,6 @@ class RetroGameScreen : Routes.Route() {
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"]?:"")
|
||||
resetGame()
|
||||
while (true) {
|
||||
delay(16)
|
||||
@@ -197,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -208,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
|
||||
.padding(bottom = bottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
if (isAphelion) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["title"] ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -899,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -568,3 +568,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
|
||||
val horizontalPadding = 24.dp
|
||||
val horizontalPadding = 24.dp
|
||||
val bottomPadding = routes.bottomPadding
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
@@ -74,10 +74,12 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = controlsHeight, bottom = bottomPadding + 4.dp),
|
||||
.padding(bottom = bottomPadding + 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(controlsHeight))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
@@ -111,9 +113,6 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 3 && tapCount.intValue < 5) {
|
||||
context.shortToast(translation.format("magic_toast", "count" to (5 - tapCount.intValue).toString()))
|
||||
}
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
@@ -216,7 +215,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
@@ -231,6 +230,7 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = scrollState.value,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
title = context.translation["manager.routes.home_logs"] ?: "Logs",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (isRefreshing) {
|
||||
|
||||
@@ -8,7 +8,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -46,11 +47,17 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
@@ -58,8 +65,10 @@ import java.net.URLEncoder
|
||||
@Composable
|
||||
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val listState = rememberLazyListState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -69,8 +78,15 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
LaunchedEffect(scrollState.value) {
|
||||
routes.navigation?.globalScrollOffset = scrollState.value
|
||||
val computedScrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
|
||||
else listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(computedScrollOffset) {
|
||||
routes.navigation?.globalScrollOffset = computedScrollOffset
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -106,162 +122,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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
/**
|
||||
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
|
||||
* Provides more nuanced feedback than standard Compose haptics.
|
||||
*/
|
||||
object AphelionHaptics {
|
||||
|
||||
/**
|
||||
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
|
||||
*/
|
||||
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
val vibrator = vibratorManager?.defaultVibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
// Fallback for older APIs
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a "soft" impact feel, good for glass interactions.
|
||||
*/
|
||||
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||
} else {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
|
||||
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
private const val REVEAL_DURATION_MS = 3200
|
||||
private const val WAVE_BAND_WIDTH_PX = 300f
|
||||
|
||||
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
|
||||
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
|
||||
|
||||
@Composable
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
fun CircularRevealOverlay(
|
||||
context: RemoteSideContext,
|
||||
request: ThemeRevealRequest,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
// Safety check: if bitmap was recycled or is null, skip.
|
||||
val bitmap = request.oldThemeBitmap ?: run {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
if (bitmap.isRecycled) {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// Ensure the reveal is cleared even if navigation happens mid-animation
|
||||
DisposableEffect(request.id) {
|
||||
onDispose { onComplete() }
|
||||
}
|
||||
|
||||
val maxRadius = remember(configuration) {
|
||||
with(density) {
|
||||
val w = configuration.screenWidthDp.dp.toPx()
|
||||
val h = configuration.screenHeightDp.dp.toPx()
|
||||
sqrt(w * w + h * h)
|
||||
}
|
||||
}
|
||||
|
||||
val animatedRadius = remember(request.id) { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(request.id) {
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
animatedRadius.animateTo(
|
||||
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
|
||||
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
|
||||
val timeValue by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 10f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
|
||||
label = "wave_time_value"
|
||||
)
|
||||
|
||||
// --- AGSL SHADER LOGIC (Android 13+) ---
|
||||
|
||||
val runtimeShader = remember(bitmap) {
|
||||
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
|
||||
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
|
||||
}
|
||||
}
|
||||
|
||||
val shaderPaint = remember(runtimeShader) {
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = runtimeShader
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radius = animatedRadius.value
|
||||
val center = request.originCenter
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
runtimeShader.setFloatUniform("revealRadius", radius)
|
||||
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
|
||||
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
|
||||
runtimeShader.setFloatUniform("time", timeValue)
|
||||
runtimeShader.setFloatUniform("uProgress", progress)
|
||||
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
/**
|
||||
* Carries all data needed to execute one theme reveal transition.
|
||||
*/
|
||||
data class ThemeRevealRequest(
|
||||
val newThemeId: String,
|
||||
val originCenter: Offset,
|
||||
val oldThemeBitmap: android.graphics.Bitmap?,
|
||||
val id: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Observable state that lives on the [Navigation] instance.
|
||||
* Optimized for stability during rapid toggle events.
|
||||
*/
|
||||
class ThemeRevealState {
|
||||
|
||||
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Requests a new theme reveal animation.
|
||||
* Always starts a new reveal immediately, even if one is already in progress.
|
||||
*/
|
||||
fun requestReveal(
|
||||
newThemeId: String,
|
||||
originCenter: Offset,
|
||||
bitmap: android.graphics.Bitmap?
|
||||
) {
|
||||
// Clean up the old one first to prevent memory leaks and "dead periods"
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
|
||||
// Immediately update with the new request ID to force a fresh animation
|
||||
pendingReveal = ThemeRevealRequest(
|
||||
newThemeId = newThemeId,
|
||||
originCenter = originCenter,
|
||||
oldThemeBitmap = bitmap,
|
||||
id = System.currentTimeMillis() // Unique ID ensures fresh start
|
||||
)
|
||||
}
|
||||
|
||||
/** Called by the overlay composable once the animation has fully completed. */
|
||||
fun clearReveal() {
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
pendingReveal = null
|
||||
|
||||
// Manual memory management for the heavy screenshot bitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
/**
|
||||
* Finalized "Perfect Optic" AGSL Shader.
|
||||
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
|
||||
*/
|
||||
object WaveEdgeShader {
|
||||
|
||||
const val AGSL = """
|
||||
uniform shader content;
|
||||
uniform float revealRadius;
|
||||
uniform float2 revealCenter;
|
||||
uniform float bandWidth;
|
||||
uniform float time;
|
||||
uniform float uProgress;
|
||||
|
||||
float hash(float2 p) {
|
||||
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float valueNoise(float2 p) {
|
||||
float2 i = floor(p);
|
||||
float2 f = fract(p);
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
half4 main(float2 pos) {
|
||||
float d = distance(pos, revealCenter);
|
||||
|
||||
float energy = (1.0 - uProgress);
|
||||
// Slower, more majestic large noise
|
||||
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
|
||||
float totalNoise = noiseLarge * energy;
|
||||
|
||||
float dist = d - (revealRadius + totalNoise);
|
||||
|
||||
if (dist > 0.0) {
|
||||
return content.eval(pos);
|
||||
}
|
||||
|
||||
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
|
||||
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
|
||||
float effectStart = revealRadius - dynamicBand;
|
||||
|
||||
if (dist < -dynamicBand) {
|
||||
return half4(0.0);
|
||||
}
|
||||
|
||||
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
|
||||
|
||||
// Asymmetric crest: Sharp start, long slow tail
|
||||
float waveShape = pow(bandProgress, 2.0);
|
||||
|
||||
float2 dir = normalize(pos - revealCenter + 0.001);
|
||||
|
||||
// Refraction: Impactful but clear
|
||||
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
|
||||
float2 refractedPos = pos + dir * refractionAmt;
|
||||
|
||||
// Chromatic Aberration: Deep prism split
|
||||
float aberration = waveShape * (35.0 * energy + 10.0);
|
||||
half r = content.eval(refractedPos + dir * aberration).r;
|
||||
half g = content.eval(refractedPos).g;
|
||||
half b = content.eval(refractedPos - dir * aberration).b;
|
||||
|
||||
// Sharp highlight at the front edge
|
||||
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
|
||||
|
||||
// Leading edge softening (minor)
|
||||
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
|
||||
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
|
||||
|
||||
return half4(
|
||||
r + half(highlight),
|
||||
g + half(highlight),
|
||||
b + half(highlight),
|
||||
half(alpha)
|
||||
);
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.8").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("288").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
## v1.4.8
|
||||
- Fix: Crash Issues for some devices
|
||||
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
|
||||
- Fix: Both side call recording for newer versions of snapchat
|
||||
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
|
||||
|
||||
## v1.4.6
|
||||
- Fix: Half Swipe Notifications for newer versions of snapchat
|
||||
- Fix: No Config import/export button if Aphelion theme is turned off
|
||||
|
||||
## v1.4.5
|
||||
- New: Spoof Snap Score Locally(tq to RSR)
|
||||
- New: Video Recording Timer(tq to RSR)
|
||||
- New: Auto Skip(tq to RSR)
|
||||
- Fix: Processing failed to save snaps(tq to RSR)
|
||||
- Fix: Story counter & story source for newer versions of snapchat(tq to RSR)
|
||||
- Fix: Features page using Aphelion scroll even when Aphelion is not in use(tq to Kaladin)
|
||||
- Fix: Aphelion Task page not showing the thumbnails of the completed tasks(tq to Kaladin)
|
||||
- Fix: Tasks page not showing the merge button when Aphelion is not in use(tq to Kaladin)
|
||||
- Fix: Features and its sub pages scroll state is remembered infintely, causing the pages to load where they were exited even after the app is closed(tq to Kaladin)
|
||||
- New: Expanded Friend Mutation Observer to include the new layout when Aphelion is in use(tq to Kaladin)
|
||||
- New: Theme Transition Animation(tq to Kaladin)
|
||||
- Fix: Friend feed menu button overlapping for group chats
|
||||
- Fix: Duplicate opera download & mark snaps as seen buttons
|
||||
|
||||
## v1.4.1
|
||||
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
|
||||
- Fix: Story Counter/Story Source position(tq to AhmedRaza)
|
||||
|
||||
@@ -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": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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+")) } }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.eternal.purrfectsnap.common.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Shared palette for PurrfectSnap overlay UI (dialogs, story overlays) shown inside Snapchat.
|
||||
* Matches the PurrfectSnap manager app's premium look. Used by core module.
|
||||
*/
|
||||
object PurrfectOverlayPalette {
|
||||
val glowPrimary = Color(0xFF8C7BFF)
|
||||
val glowSecondary = Color(0xFF5FD8FF)
|
||||
val textPrimary = Color.White
|
||||
val textSecondary = Color(0xFFD9D3FF)
|
||||
val cardOverlayColor = Color(0xFF2A2452).copy(alpha = 0.95f)
|
||||
val cardOverlay = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package me.eternal.purrfectsnap.common.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun AphelionFriendMutationToast(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
bitmojiUrl: String?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
var bitmojiBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(bitmojiUrl) {
|
||||
if (bitmojiUrl != null) {
|
||||
runCatching {
|
||||
me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, _ ->
|
||||
bitmojiBitmap = android.graphics.BitmapFactory.decodeStream(inputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
delay(5000)
|
||||
visible = false
|
||||
delay(500)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow),
|
||||
label = "progress"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 16.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationY = -100f * (1f - progress)
|
||||
alpha = progress
|
||||
scaleX = 0.9f + (0.1f * progress)
|
||||
scaleY = 0.9f + (0.1f * progress)
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 340.dp)
|
||||
.shadow(20.dp, RoundedCornerShape(28.dp)),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = Color(0xE61B152E),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.15f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (bitmojiBitmap != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = bitmojiBitmap!!.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ dependencies {
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.rhino)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
|
||||
|
||||
implementation(project(":common"))
|
||||
implementation(project(":mapper"))
|
||||
@@ -47,6 +49,8 @@ dependencies {
|
||||
implementation(libs.androidx.material.ripple)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.hiddenapibypass)
|
||||
implementation(libs.colorpicker.compose)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Cancel
|
||||
import androidx.compose.runtime.Composable
|
||||
import java.lang.reflect.Method
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -32,12 +34,14 @@ import me.eternal.purrfectsnap.core.data.SnapClassCache
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
|
||||
import me.eternal.purrfectsnap.core.ui.InAppOverlay
|
||||
import me.eternal.purrfectsnap.core.ui.CustomComposable
|
||||
import me.eternal.purrfectsnap.core.util.LSPatchUpdater
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookAdapter
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.mapper.impl.PlatformClientAttestationMapper
|
||||
import me.eternal.purrfectsnap.common.ui.components.AphelionFriendMutationToast
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.system.measureTimeMillis
|
||||
@@ -216,6 +220,26 @@ class PurrfectSnap {
|
||||
log.verbose("Initializing features...")
|
||||
runCatching {
|
||||
features.init()
|
||||
|
||||
// Wire up the premium friend mutation toast provider
|
||||
features.get(me.eternal.purrfectsnap.core.features.impl.FriendMutationObserver::class)?.let { observer ->
|
||||
observer.aphelionToastProvider = { icon, text, bitmojiUrl, onDismiss ->
|
||||
lateinit var composable: CustomComposable
|
||||
composable = @Composable {
|
||||
AphelionFriendMutationToast(
|
||||
icon = icon,
|
||||
text = text,
|
||||
bitmojiUrl = bitmojiUrl,
|
||||
onDismiss = {
|
||||
inAppOverlay.removeCustomComposable(composable)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
inAppOverlay.addCustomComposable(composable)
|
||||
}
|
||||
}
|
||||
|
||||
log.verbose("Features initialized successfully")
|
||||
}.onFailure { throwable ->
|
||||
log.error("Failed to initialize features", throwable)
|
||||
|
||||
@@ -154,12 +154,14 @@ class FeatureManager(
|
||||
AutoDeleteSentMessages(),
|
||||
FriendNotes(),
|
||||
DoubleTapChatAction(),
|
||||
VideoRecordTimer(),
|
||||
SnapScoreChanges(),
|
||||
DisableSnapModeRestrictions(),
|
||||
MessageTranslator(),
|
||||
PreventForcedKeyboard(),
|
||||
CustomTheming(),
|
||||
HideTypingIndicator(),
|
||||
FakeSnapScore(),
|
||||
)
|
||||
|
||||
features.values.toList().forEach { feature ->
|
||||
|
||||
@@ -4,13 +4,13 @@ import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.google.gson.JsonObject
|
||||
import me.eternal.purrfectsnap.common.data.FriendLinkType
|
||||
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import java.io.InputStreamReader
|
||||
import java.util.Calendar
|
||||
@@ -23,25 +23,28 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
private val channelId by lazy {
|
||||
"friend_mutation_observer".also {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
it,
|
||||
translation["notification_channel_name"],
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
NotificationChannel(it, translation["notification_channel_name"], NotificationManager.IMPORTANCE_HIGH)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFriendAddSource(userId: String): String? {
|
||||
return addSourceCache[userId]
|
||||
}
|
||||
// Injected from app layer — keeps core free of ui.* imports
|
||||
var aphelionToastProvider: ((
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
bitmojiUrl: String?,
|
||||
onDismiss: () -> Unit
|
||||
) -> Unit)? = null
|
||||
|
||||
fun getFriendAddSource(userId: String): String? = addSourceCache[userId]
|
||||
|
||||
private fun sendMutationNotification(icon: ImageVector, contentText: String, friendInfo: FriendInfo? = null) {
|
||||
val currentTheme = context.config.global.uiSettings.managerTheme.get()
|
||||
val isAphelion = currentTheme == "APHELION"
|
||||
|
||||
private fun sendWarnNotification(
|
||||
contentText: String
|
||||
) {
|
||||
notificationManager.notify(System.nanoTime().toInt(),
|
||||
Notification.Builder(context.androidContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(translation["notification_channel_name"])
|
||||
.setContentText(contentText)
|
||||
.setShowWhen(true)
|
||||
@@ -49,11 +52,21 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
.build()
|
||||
)
|
||||
|
||||
context.inAppOverlay.showStatusToast(
|
||||
Icons.Default.WarningAmber,
|
||||
contentText,
|
||||
durationMs = 7000
|
||||
)
|
||||
val provider = aphelionToastProvider
|
||||
if (isAphelion && provider != null) {
|
||||
val bitmojiUrl = friendInfo?.let {
|
||||
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.getBitmojiSelfie(
|
||||
it.bitmojiSelfieId,
|
||||
it.bitmojiAvatarId,
|
||||
me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
|
||||
)
|
||||
}
|
||||
provider(icon, contentText, bitmojiUrl) {
|
||||
// onDismiss handled inside the provider lambda in app layer
|
||||
}
|
||||
} else {
|
||||
context.inAppOverlay.showStatusToast(icon, contentText, durationMs = 7000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUsername(friendInfo: FriendInfo): String {
|
||||
@@ -65,44 +78,34 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
private fun prettyPrintBirthday(month: Int, day: Int): String {
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar[Calendar.MONTH] = month
|
||||
return calendar.getDisplayName(
|
||||
Calendar.MONTH,
|
||||
Calendar.LONG,
|
||||
context.translation.loadedLocale
|
||||
)?.toString() + " " + day
|
||||
return calendar.getDisplayName(Calendar.MONTH, Calendar.LONG, context.translation.loadedLocale)?.toString() + " " + day
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
val config by context.config.messaging.friendMutationNotifier
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!event.url.contains("ami/friends")) return@subscribe
|
||||
event.onSuccess { buffer ->
|
||||
runCatching {
|
||||
val jsonObject = context.gson.fromJson(InputStreamReader(buffer?.inputStream() ?: return@onSuccess, Charsets.UTF_8), JsonObject::class.java)
|
||||
|
||||
jsonObject.getAsJsonArray("added_friends").map { it.asJsonObject }.forEach { friend ->
|
||||
jsonObject.getAsJsonArray("added_friends")?.map { it.asJsonObject }?.forEach { friend ->
|
||||
val userId = friend.get("user_id").asString
|
||||
(friend.get("add_source")?.asString?.takeIf {
|
||||
it.isNotBlank()
|
||||
} ?: friend.get("add_source_type")?.asString?.takeIf {
|
||||
it.isNotBlank()
|
||||
})?.let {
|
||||
(friend.get("add_source")?.asString?.takeIf { it.isNotBlank() }
|
||||
?: friend.get("add_source_type")?.asString?.takeIf { it.isNotBlank() })?.let {
|
||||
addSourceCache[userId] = it
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isEmpty()) return@runCatching
|
||||
|
||||
jsonObject.getAsJsonArray("friends").map { it.asJsonObject }.forEach { friend ->
|
||||
jsonObject.getAsJsonArray("friends")?.map { it.asJsonObject }?.forEach { friend ->
|
||||
runCatching {
|
||||
val userId = friend.get("user_id")?.asString
|
||||
val userId = friend.get("user_id")?.asString ?: return@forEach
|
||||
if (userId == context.database.myUserId) return@forEach
|
||||
val databaseFriend = context.database.getFriendInfo(userId ?: return@forEach) ?: return@forEach
|
||||
val databaseFriend = context.database.getFriendInfo(userId) ?: return@forEach
|
||||
if (FriendLinkType.fromValue(databaseFriend.friendLinkType) != FriendLinkType.MUTUAL) return@forEach
|
||||
|
||||
if (config.contains("remove_friend") && friend.get("direction")?.asString == "OUTGOING" && !friend.has("fidelius_info")) {
|
||||
sendWarnNotification(translation.format("friend_removed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.PersonRemove, translation.format("friend_removed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
@@ -111,47 +114,38 @@ class FriendMutationObserver: Feature("FriendMutationObserver") {
|
||||
((it shr 32).toInt()).toString().padStart(2, '0') + "-" + (it.toInt()).toString().padStart(2, '0')
|
||||
} != friend.get("birthday")?.asString
|
||||
) {
|
||||
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let {
|
||||
prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt())
|
||||
}
|
||||
|
||||
val oldBirthday = databaseFriend.birthday.takeIf { it != 0L }?.let { prettyPrintBirthday((it shr 32).toInt() - 1, it.toInt()) }
|
||||
if (!friend.has("birthday")) {
|
||||
sendWarnNotification(translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_removed", "username" to formatUsername(databaseFriend), "birthday" to oldBirthday.orEmpty()), databaseFriend)
|
||||
} else {
|
||||
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let {
|
||||
prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt())
|
||||
}
|
||||
val newBirthday = friend.get("birthday")?.asString?.split("-")?.let { prettyPrintBirthday(it[0].toInt() - 1, it[1].toInt()) }
|
||||
if (oldBirthday == null) {
|
||||
sendWarnNotification(translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_added", "username" to formatUsername(databaseFriend), "birthday" to newBirthday.orEmpty()), databaseFriend)
|
||||
} else {
|
||||
sendWarnNotification(translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()))
|
||||
sendMutationNotification(Icons.Default.Cake, translation.format("birthday_changed", "username" to formatUsername(databaseFriend), "oldBirthday" to oldBirthday, "newBirthday" to newBirthday.orEmpty()), databaseFriend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_avatar_changes") && databaseFriend.bitmojiAvatarId != friend.get("bitmoji_avatar_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_avatar_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_selfie_changes") && databaseFriend.bitmojiSelfieId != friend.get("bitmoji_selfie_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Face, translation.format("bitmoji_selfie_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_background_changes") && databaseFriend.bitmojiBackgroundId != friend.get("bitmoji_background_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Image, translation.format("bitmoji_background_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
|
||||
if (config.contains("bitmoji_scene_changes") && databaseFriend.bitmojiSceneId != friend.get("bitmoji_scene_id")?.asString) {
|
||||
sendWarnNotification(translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)))
|
||||
sendMutationNotification(Icons.Default.Landscape, translation.format("bitmoji_scene_changed", "username" to formatUsername(databaseFriend)), databaseFriend)
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to process friend", it)
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to process friend", it) }
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to process friends", it)
|
||||
}
|
||||
}.onFailure { context.log.error("Failed to process friends", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.core.ui.InAppOverlay
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
@@ -26,12 +31,21 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private var wasInCall = false
|
||||
private var callDownloadSession: CallDownloadSession? = null
|
||||
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
|
||||
private val activeRemoteStreams = ConcurrentHashMap.newKeySet<Int>()
|
||||
private var fallbackMicRecord: AudioRecord? = null
|
||||
private var fallbackMicJob: Job? = null
|
||||
private var fallbackMicStartupJob: Job? = null
|
||||
private var pendingCallEndJob: Job? = null
|
||||
private var lastRemoteActivityTimestamp = 0L
|
||||
private var selfSideStreamOpened = false
|
||||
|
||||
private val uiState get() = context.inAppOverlay.callRecorderState
|
||||
private val callRecorderConfig get() = context.config.downloader.callRecorder
|
||||
|
||||
inner class CallStreamWrapper(
|
||||
private val audioFormat: AudioFormat,
|
||||
private val sourceLabel: String = "unknown",
|
||||
private val onStreamOpened: (() -> Unit)? = null,
|
||||
private val startTimestamp: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
private var stream: OutputStream? = null
|
||||
@@ -49,6 +63,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
audioFormat.encoding
|
||||
) ?: return
|
||||
)
|
||||
context.log.verbose(
|
||||
"Opened call stream source=$sourceLabel sampleRate=${audioFormat.sampleRate} channels=${audioFormat.channelCount} encoding=${audioFormat.encoding}",
|
||||
"CallRecorder"
|
||||
)
|
||||
onStreamOpened?.invoke()
|
||||
}
|
||||
}
|
||||
runCatching { stream?.write(buffer) }
|
||||
@@ -63,6 +82,9 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun finalizeSession() {
|
||||
val session = callDownloadSession ?: return
|
||||
context.log.verbose("Finalizing call recording session")
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("finalizeSession")
|
||||
runCatching { session.end() }
|
||||
callDownloadSession = null
|
||||
streams.values.forEach { it.close() }
|
||||
@@ -80,12 +102,14 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
|
||||
ensureSessionStarted()
|
||||
scheduleFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
if (uiState.isRecording) {
|
||||
uiState.isRecording = false
|
||||
stopFallbackMicCapture("stopRecording")
|
||||
finalizeSession()
|
||||
}
|
||||
}
|
||||
@@ -93,6 +117,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallStarted(conversationId: String) {
|
||||
if (wasInCall) return
|
||||
wasInCall = true
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
selfSideStreamOpened = false
|
||||
|
||||
val author = (if (context.database.getConversationType(conversationId) == 1) {
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
|
||||
@@ -120,6 +149,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallEnded() {
|
||||
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
|
||||
wasInCall = false
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("onCallEnded")
|
||||
finalizeSession()
|
||||
streams.clear()
|
||||
|
||||
@@ -178,6 +212,36 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamActive(streamId: Int, reason: String) {
|
||||
lastRemoteActivityTimestamp = System.currentTimeMillis()
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
if (activeRemoteStreams.add(streamId)) {
|
||||
context.log.verbose("Remote stream active id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamInactive(streamId: Int, reason: String) {
|
||||
if (activeRemoteStreams.remove(streamId)) {
|
||||
context.log.verbose("Remote stream inactive id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
scheduleCallEndCheck(reason)
|
||||
}
|
||||
|
||||
private fun scheduleCallEndCheck(reason: String, delayMs: Long = 1500L) {
|
||||
if (!wasInCall || lastRemoteActivityTimestamp == 0L || activeRemoteStreams.isNotEmpty()) return
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = context.coroutineScope.launch {
|
||||
delay(delayMs)
|
||||
if (!wasInCall) return@launch
|
||||
if (activeRemoteStreams.isNotEmpty()) return@launch
|
||||
val idleFor = System.currentTimeMillis() - lastRemoteActivityTimestamp
|
||||
if (idleFor < delayMs) return@launch
|
||||
context.log.verbose("Call end detected via remote inactivity reason=$reason idleFor=${idleFor}ms", "CallRecorder")
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSessionStarted() {
|
||||
if (callDownloadSession != null) return
|
||||
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
@@ -186,6 +250,209 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
onCallStarted(conversationId)
|
||||
}
|
||||
|
||||
private fun isCallContextActive(): Boolean {
|
||||
return wasInCall || uiState.showOverlay || uiState.isRecording
|
||||
}
|
||||
|
||||
private fun isDirectVoiceCaptureSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_CALL ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_UPLINK
|
||||
}
|
||||
|
||||
private fun isLikelyCallMicSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.DEFAULT ||
|
||||
audioSource == MediaRecorder.AudioSource.MIC ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_RECOGNITION ||
|
||||
audioSource == MediaRecorder.AudioSource.UNPROCESSED ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE
|
||||
}
|
||||
|
||||
private fun registerAudioRecordStream(audioRecord: AudioRecord, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioRecord.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val audioSource = runCatching { audioRecord.audioSource }.getOrNull()
|
||||
val shouldCapture = isDirectVoiceCaptureSource(audioSource) ||
|
||||
(isCallContextActive() && isLikelyCallMicSource(audioSource))
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioRecord.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "self-internal:$reason",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
if (audioRecord !== fallbackMicRecord) {
|
||||
stopFallbackMicCapture("internalSelfStreamOpened")
|
||||
}
|
||||
}
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
context.log.verbose(
|
||||
"Registered AudioRecord stream source=$audioSource reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isDirectVoiceCaptureSource(audioSource) || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCaptureSelfSide(): Boolean {
|
||||
return callRecorderConfig.callRecorder.get() != "only_record_others"
|
||||
}
|
||||
|
||||
private fun scheduleFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null) return
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = context.coroutineScope.launch {
|
||||
delay(1200)
|
||||
if (!isActive || !uiState.isRecording || selfSideStreamOpened || fallbackMicJob != null) return@launch
|
||||
startFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null || !uiState.isRecording) return
|
||||
|
||||
val sampleRate = 48_000
|
||||
val channelMask = AudioFormat.CHANNEL_IN_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
|
||||
if (minBufferSize <= 0) {
|
||||
context.log.warn("Fallback mic capture unavailable: invalid min buffer size $minBufferSize", "CallRecorder")
|
||||
return
|
||||
}
|
||||
|
||||
val audioFormat = AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask)
|
||||
.setEncoding(encoding)
|
||||
.build()
|
||||
|
||||
val audioRecord = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(audioFormat)
|
||||
.setBufferSizeInBytes(minBufferSize * 2)
|
||||
.build()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to create fallback mic recorder", it)
|
||||
return
|
||||
}
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
context.log.warn("Fallback mic recorder failed to initialize", "CallRecorder")
|
||||
runCatching { audioRecord.release() }
|
||||
return
|
||||
}
|
||||
|
||||
fallbackMicRecord = audioRecord
|
||||
context.log.verbose("Starting fallback mic capture", "CallRecorder")
|
||||
|
||||
fallbackMicJob = context.coroutineScope.launch(Dispatchers.IO) {
|
||||
val buffer = ByteArray(minBufferSize.coerceAtLeast(2048))
|
||||
val fallbackWrapper = CallStreamWrapper(
|
||||
audioFormat = audioFormat,
|
||||
sourceLabel = "self-fallback",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
}
|
||||
)
|
||||
val echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
val noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
try {
|
||||
audioRecord.startRecording()
|
||||
while (isActive && uiState.isRecording && isCallContextActive() && fallbackMicRecord === audioRecord) {
|
||||
val bytesRead = runCatching {
|
||||
audioRecord.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||
}.getOrElse {
|
||||
context.log.error("Fallback mic read failed", it)
|
||||
break
|
||||
}
|
||||
|
||||
if (bytesRead > 0) {
|
||||
fallbackWrapper.write(buffer.copyOf(bytesRead))
|
||||
} else {
|
||||
delay(10)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
context.log.error("Fallback mic capture crashed", e)
|
||||
} finally {
|
||||
fallbackWrapper.close()
|
||||
runCatching { audioRecord.stop() }
|
||||
echoCanceler?.release()
|
||||
noiseSuppressor?.release()
|
||||
runCatching { audioRecord.release() }
|
||||
if (fallbackMicRecord === audioRecord) {
|
||||
fallbackMicRecord = null
|
||||
fallbackMicJob = null
|
||||
}
|
||||
context.log.verbose("Stopped fallback mic capture", "CallRecorder")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFallbackMicCapture(reason: String) {
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = null
|
||||
if (fallbackMicJob != null || fallbackMicRecord != null) {
|
||||
context.log.verbose("Stopping fallback mic capture reason=$reason", "CallRecorder")
|
||||
}
|
||||
fallbackMicJob?.cancel()
|
||||
fallbackMicJob = null
|
||||
fallbackMicRecord?.let { record ->
|
||||
runCatching { record.stop() }
|
||||
runCatching { record.release() }
|
||||
}
|
||||
fallbackMicRecord = null
|
||||
}
|
||||
|
||||
private fun isVoiceCommunicationTrack(attributes: AudioAttributes?, streamType: Int?): Boolean {
|
||||
return attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
}
|
||||
|
||||
private fun registerAudioTrackStream(audioTrack: AudioTrack, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioTrack.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val attributes = runCatching { audioTrack.audioAttributes }.getOrNull()
|
||||
val streamType = runCatching { audioTrack.streamType }.getOrNull()
|
||||
val isVoiceCommunication = isVoiceCommunicationTrack(attributes, streamType)
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(isCallContextActive() && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioTrack.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "remote:$reason"
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
markRemoteStreamActive(streamId, "register:$reason")
|
||||
context.log.verbose(
|
||||
"Registered AudioTrack stream streamType=$streamType usage=${attributes?.usage} reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isVoiceCommunication || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
|
||||
if (requestedLength <= 0 || maxLength <= 0) return null
|
||||
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
|
||||
@@ -209,6 +476,13 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFloatArrayToByteArray(data: FloatArray, offset: Int, sampleCount: Int): ByteArray? {
|
||||
val (safeOffset, safeLength) = clampCopyRange(offset, sampleCount, data.size) ?: return null
|
||||
return ByteArray(safeLength * Float.SIZE_BYTES).also {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (callRecorderConfig.callRecorder.getNullable() == null) return
|
||||
|
||||
@@ -224,30 +498,16 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioRecord::class.java.apply {
|
||||
if (recorderConfig == "only_record_others") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val audioSource = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioRecordStream(param.thisObject<AudioRecord>(), "constructor")
|
||||
}
|
||||
|
||||
hook("read", HookStage.AFTER) { param ->
|
||||
val result = param.getResult() as? Int ?: 0
|
||||
if (result <= 0) return@hook
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val audioRecord = param.thisObject<AudioRecord>()
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()]
|
||||
?: registerAudioRecordStream(audioRecord, "read")
|
||||
?: return@hook
|
||||
|
||||
val buffer = when (val data = param.arg<Any>(0)) {
|
||||
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
|
||||
@@ -263,11 +523,19 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
copyFloatArrayToByteArray(data, offset, result) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("startRecording", HookStage.AFTER) {
|
||||
registerAudioRecordStream(it.thisObject<AudioRecord>(), "startRecording")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("release", HookStage.BEFORE) {
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
@@ -278,29 +546,15 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioTrack::class.java.apply {
|
||||
if (recorderConfig == "only_record_self") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val streamType = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioTrackStream(param.thisObject<AudioTrack>(), "constructor")
|
||||
}
|
||||
|
||||
hook("write", HookStage.BEFORE) { param ->
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val streamId = param.thisObject<Any>().hashCode()
|
||||
markRemoteStreamActive(streamId, "write")
|
||||
val wrapper = streams[streamId]
|
||||
?: registerAudioTrackStream(param.thisObject<AudioTrack>(), "write")
|
||||
?: return@hook
|
||||
val data = param.arg<Any>(0)
|
||||
|
||||
val buffer = when (data) {
|
||||
@@ -328,13 +582,34 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
val requestedSize = param.argNullable<Int>(2) ?: data.size
|
||||
copyFloatArrayToByteArray(data, offset, requestedSize) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("play", HookStage.AFTER) {
|
||||
val audioTrack = it.thisObject<AudioTrack>()
|
||||
markRemoteStreamActive(audioTrack.hashCode(), "play")
|
||||
registerAudioTrackStream(audioTrack, "play")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "stop")
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
hook("pause", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "pause")
|
||||
}
|
||||
hook("flush", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "flush")
|
||||
}
|
||||
hook("release", HookStage.BEFORE) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "release")
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,20 @@ import android.graphics.drawable.shapes.Shape
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
@@ -29,9 +36,10 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.*
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.features.impl.ui.ConversationToolbox
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.ui.findParent
|
||||
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
@@ -185,6 +193,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveKeyActionContainer(startView: View): ViewGroup? {
|
||||
val ancestors = generateSequence(startView) { current ->
|
||||
current.parent as? View
|
||||
}.filterIsInstance<ViewGroup>().toList()
|
||||
|
||||
return ancestors.firstOrNull { candidate ->
|
||||
candidate is LinearLayout && candidate.orientation == LinearLayout.VERTICAL
|
||||
} ?: ancestors.firstOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n", "DiscouragedApi")
|
||||
override fun init() {
|
||||
if (!isEnabled) return
|
||||
@@ -264,9 +282,7 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.chatMessage { conversationId, messageId ->
|
||||
val viewGroup = event.view.findParent(maxIteration = 3) {
|
||||
it is LinearLayout
|
||||
} as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage
|
||||
val viewGroup = resolveKeyActionContainer(event.view) ?: return@chatMessage
|
||||
|
||||
viewGroup.findViewWithTag<View>(specialCard)?.also {
|
||||
viewGroup.removeView(it)
|
||||
@@ -289,27 +305,45 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
val publicKey = pkRequests[messageId.toLong()]
|
||||
|
||||
if (publicKey != null || secret != null) {
|
||||
viewGroup.addView(createComposeView(context.mainActivity!!) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
onClick = {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
) {
|
||||
createComposeView(viewGroup.context) {
|
||||
PurrfectOverlayTheme {
|
||||
val actionShape = RoundedCornerShape(22.dp)
|
||||
val borderBrush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.70f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(translation["accept_public_key_button"])
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(translation["accept_secret_button"])
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(actionShape)
|
||||
.background(PurrfectOverlayPalette.cardOverlay, actionShape)
|
||||
.border(1.15.dp, borderBrush, actionShape)
|
||||
.padding(horizontal = 18.dp, vertical = 11.dp)
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(
|
||||
text = translation["accept_public_key_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(
|
||||
text = translation["accept_secret_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +353,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
})
|
||||
setOnClickListener {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
viewGroup.addView(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
))
|
||||
} }
|
||||
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
|
||||
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
|
||||
|
||||
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
|
||||
peekingStateListeners.add(listener)
|
||||
}
|
||||
|
||||
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
|
||||
return runCatching {
|
||||
@@ -207,6 +212,12 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
else -> {}
|
||||
}
|
||||
|
||||
when (eventType) {
|
||||
TrackerEventType.STARTED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, true) }
|
||||
TrackerEventType.STOPPED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, false) }
|
||||
else -> {}
|
||||
}
|
||||
|
||||
dispatchEvents(eventType, conversationId, userId)
|
||||
}
|
||||
|
||||
@@ -261,7 +272,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
typing = stateMap[4],
|
||||
wasTyping = stateMap[5],
|
||||
speaking = stateMap[6] && stateMap[4],
|
||||
peeking = stateMap[8]
|
||||
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
|
||||
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,7 +397,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
|
||||
override fun init() {
|
||||
val sessionEventsConfig = context.config.friendTracker
|
||||
if (sessionEventsConfig.globalState != true) return
|
||||
val shouldProcessSessionEvents = sessionEventsConfig.globalState == true || peekingStateListeners.isNotEmpty()
|
||||
if (!shouldProcessSessionEvents) return
|
||||
|
||||
if (sessionEventsConfig.allowRunningInBackground.get()) {
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
@@ -402,7 +415,7 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionEventsConfig.recordMessagingEvents.get()) {
|
||||
if (sessionEventsConfig.recordMessagingEvents.get() || peekingStateListeners.isNotEmpty()) {
|
||||
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
|
||||
hook("onReceive", HookStage.BEFORE) { param ->
|
||||
param.setResult(null)
|
||||
|
||||
@@ -6,17 +6,10 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
private val peekingConversations = ConcurrentHashMap<String, List<String>>()
|
||||
private val startPeekingTimestamps = ConcurrentHashMap<String, Long>()
|
||||
private val startPeekingTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private val halfSwipeListeners = mutableListOf<(String, String, Long) -> Unit>()
|
||||
|
||||
private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
@@ -39,44 +32,11 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.messaging.halfSwipeNotifier.globalState != true) return
|
||||
lateinit var presenceService: Any
|
||||
|
||||
findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) {
|
||||
presenceService = it.thisObject()
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
callbacks.getClass("PresenceServiceDelegate")?.hook("notifyActiveConversationsChanged", HookStage.BEFORE) {
|
||||
val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants)
|
||||
|
||||
if (activeConversations.isEmpty()) {
|
||||
peekingConversations.forEach {
|
||||
val conversationId = it.key
|
||||
val peekingParticipantsIds = it.value
|
||||
peekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
peekingConversations.clear()
|
||||
return@hook
|
||||
}
|
||||
|
||||
activeConversations.forEach { (conversationId, conversationInfo) ->
|
||||
val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach
|
||||
val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList()
|
||||
|
||||
val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet()
|
||||
val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet()
|
||||
|
||||
newPeekingParticipantsIds.forEach { userId ->
|
||||
startPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
|
||||
exitedPeekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
peekingConversations[conversationId.toString()] = peekingParticipantsIds
|
||||
}
|
||||
context.feature(FriendTracker::class).addOnPeekingStateChangedListener { conversationId, userId, peeking ->
|
||||
if (peeking) {
|
||||
startPeeking(conversationId, userId)
|
||||
} else {
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,4 +99,4 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Provides snap jump logic for Story Snap List Download batch downloads.
|
||||
* Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps.
|
||||
* Main overlay feature for story counter, source indicator, and Auto Skip (snap jump).
|
||||
* Also provides snap jump logic for Story Snap List Download batch downloads.
|
||||
*/
|
||||
class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private val overlayState = OperaStoryOverlayState()
|
||||
@@ -18,36 +51,173 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private lateinit var snapJump: OperaStorySnapJump
|
||||
|
||||
override fun init() {
|
||||
val showCounter = context.config.userInterface.storyCounter.get()
|
||||
val showSourceIndicator = context.config.userInterface.storySourceIndicator.get()
|
||||
val enableSnapJump = context.config.userInterface.storySnapJump.get()
|
||||
val storySnapListDownload = context.config.downloader.storySnapListDownload.get()
|
||||
val showDownloadButton = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (!storySnapListDownload) return
|
||||
if (!showCounter && !showSourceIndicator && !enableSnapJump && !storySnapListDownload && !showDownloadButton) return
|
||||
|
||||
snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() }
|
||||
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as FrameLayout
|
||||
if (event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as? ViewGroup ?: return@subscribe
|
||||
|
||||
val isWrapped = viewGroup is FrameLayout && viewGroup.childCount == 1 && viewGroup.getChildAt(0) is ViewGroup
|
||||
val actualLayer = if (isWrapped) viewGroup.getChildAt(0) as ViewGroup else viewGroup
|
||||
|
||||
if (viewGroup.findViewWithTag<View>("story_counter") != null ||
|
||||
event.parent.findViewWithTag<View>("story_counter") != null) return@subscribe
|
||||
event.parent.findViewWithTag<View>("story_counter") != null ||
|
||||
actualLayer.javaClass.name.endsWith("ScalableCircleMaskFrameLayout")
|
||||
) return@subscribe
|
||||
|
||||
if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe
|
||||
if (actualLayer.childCount > 0 && !actualLayer.javaClass.name.contains("OperaShapeView")) {
|
||||
storyFrameLayout = WeakReference(viewGroup as FrameLayout)
|
||||
viewGroup.tag = "story_counter"
|
||||
|
||||
storyFrameLayout = WeakReference(viewGroup)
|
||||
val composeView = createComposeView(viewGroup.context) {
|
||||
val counterText = overlayState.counterState.value
|
||||
val source = overlayState.sourceState.value
|
||||
val hasCounter = showCounter && counterText.isNotEmpty()
|
||||
val hasSource = showSourceIndicator && source.isNotEmpty()
|
||||
val currentIdx = overlayState.currentIndexState.intValue
|
||||
val totalCount = overlayState.totalCountState.intValue
|
||||
var showJumpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isDownloadButtonEnabled = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (hasCounter || hasSource || enableSnapJump || isDownloadButtonEnabled) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
if (hasCounter) {
|
||||
OperaStoryCounterDisplay(
|
||||
counterText = counterText,
|
||||
enableSnapJump = enableSnapJump,
|
||||
totalCount = totalCount,
|
||||
onCounterClick = { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (enableSnapJump && (hasCounter || totalCount > 1) && totalCount > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clickable { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSource) {
|
||||
if (hasCounter || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
}
|
||||
OperaStorySourceIndicatorDisplay(source = source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownloadButtonEnabled) {
|
||||
val mediaDownloader = remember { context.feature(MediaDownloader::class) }
|
||||
val snapSource = overlayState.snapSourceState.value
|
||||
val isInConversation = overlayState.isInConversationState.value
|
||||
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY" && !isInConversation) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Download,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.size(18.dp)
|
||||
.clickable {
|
||||
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableSnapJump && showJumpDialog && totalCount > 1) {
|
||||
OperaStorySnapJumpDialog(
|
||||
currentIndex = currentIdx,
|
||||
totalCount = totalCount,
|
||||
onDismiss = { showJumpDialog = false },
|
||||
onJump = { targetIndex ->
|
||||
showJumpDialog = false
|
||||
snapJump.jumpToSnap(targetIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeView.tag = "story_counter"
|
||||
composeView.layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
topMargin = this@OperaStoryOverlay.context.userInterface.dpToPx(50)
|
||||
marginEnd = this@OperaStoryOverlay.context.userInterface.dpToPx(10)
|
||||
}
|
||||
viewGroup.addView(composeView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
overlayState.setupDisplayStateHook(
|
||||
context = context,
|
||||
showCounter = false,
|
||||
showSourceIndicator = false,
|
||||
onSnapFullyDisplayed = {
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(it)
|
||||
showCounter = showCounter,
|
||||
showSourceIndicator = showSourceIndicator,
|
||||
onSnapFullyDisplayed = if (enableSnapJump) {
|
||||
{ currentIndex ->
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(currentIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClearState = { snapJump.removeJumpOverlay() }
|
||||
} else null,
|
||||
onClearState = if (enableSnapJump) { { snapJump.removeJumpOverlay() } } else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +225,46 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean =
|
||||
snapJump.requestJumpToSnap(targetIndex, totalCountOverride)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStoryCounterDisplay(
|
||||
counterText: String,
|
||||
enableSnapJump: Boolean,
|
||||
totalCount: Int,
|
||||
onCounterClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (counterText.isEmpty()) return
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.then(
|
||||
if (enableSnapJump && totalCount > 1)
|
||||
Modifier.clickable { onCounterClick() }
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = counterText,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStorySourceIndicatorDisplay(
|
||||
source: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (source.isEmpty()) return
|
||||
|
||||
val icon = if (source == "CAMERA") Icons.Outlined.CameraAlt else Icons.Outlined.PhotoLibrary
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = modifier.size(11.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -117,18 +117,29 @@ class ConversationManager(
|
||||
set("mServerConversationId", conversationId.toSnapUUID().instanceNonNull())
|
||||
set("mServerMessageId", serverMessageId)
|
||||
}
|
||||
val conversationUuid = conversationId.toSnapUUID().instanceNonNull()
|
||||
|
||||
fetchMessageByServerId.invoke(
|
||||
instanceNonNull(),
|
||||
serverMessageIdentifier,
|
||||
CallbackBuilder(getCallbackClass("FetchMessageCallback"))
|
||||
.override("onFetchMessageComplete") { param ->
|
||||
onSuccess(Message(param.arg(0)))
|
||||
}
|
||||
.override("onError") {
|
||||
onError(it.arg<Any>(0).toString())
|
||||
}.build()
|
||||
)
|
||||
val callback = CallbackBuilder(getCallbackClass("FetchMessageCallback"))
|
||||
.override("onFetchMessageComplete") { param ->
|
||||
onSuccess(Message(param.arg(0)))
|
||||
}
|
||||
.override("onError") {
|
||||
onError(it.arg<Any>(0).toString())
|
||||
}.build()
|
||||
|
||||
val args = fetchMessageByServerId.parameterTypes.mapIndexed { index, parameterType ->
|
||||
when {
|
||||
parameterType.isInstance(serverMessageIdentifier) -> serverMessageIdentifier
|
||||
parameterType.isInstance(callback) -> callback
|
||||
parameterType.isInstance(conversationUuid) -> conversationUuid
|
||||
parameterType == Boolean::class.javaPrimitiveType || parameterType == Boolean::class.javaObjectType -> false
|
||||
else -> throw IllegalStateException(
|
||||
"Unsupported fetchMessageByServerId parameter at index $index: ${parameterType.name}"
|
||||
)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
fetchMessageByServerId.invoke(instanceNonNull(), *args)
|
||||
}
|
||||
|
||||
fun fetchMessagesByServerIds(conversationId: String, serverMessageIds: List<Long>, onSuccess: (List<Message>) -> Unit, onError: (error: String) -> Unit) {
|
||||
|
||||
@@ -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.8
|
||||
APP_VERSION_CODE=288
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user