5 Commits

Author SHA1 Message Date
particle-box
26077a8c87 fix: translation 2026-03-07 04:41:46 +05:30
particle-box
28982aa655 v1.3.8 2026-03-07 04:33:21 +05:30
particle-box
89748cd261 multiple fixes & improvements 2026-03-07 00:00:36 +05:30
particle-box
97797127bd Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-06 01:49:04 +05:30
particle-box
23d8ff0370 fix: crash issue for some devices 2026-03-06 01:48:34 +05:30
31 changed files with 1466 additions and 253 deletions

View File

@@ -45,7 +45,7 @@ body:
attributes:
label: "SnapEnhance Version"
description: "On which SnapEnhance version is this happening?"
placeholder: "ex. 1.2.5"
placeholder: "ex. 1.3.6"
validations:
required: true
- type: checkboxes

View File

@@ -324,7 +324,7 @@ PurrfectSnap is built with exceptional open source tools. We do not collect any
**Core Dependencies**
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
- [libxposed](https://github.com/libxposed/api) — Framework integration
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
- [OMVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -4,6 +4,7 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -27,10 +28,26 @@ import kotlin.system.measureTimeMillis
class BridgeService : Service() {
private lateinit var remoteSideContext: RemoteSideContext
lateinit var syncCallback: SyncCallback
private var syncCallback: SyncCallback? = null
private var syncCallbackBinder: IBinder? = null
private val syncCallbackDeathRecipient = IBinder.DeathRecipient {
remoteSideContext.takeIf { ::remoteSideContext.isInitialized }?.log?.warn("Sync callback binder died")
clearSyncCallback()
}
var messagingBridge: MessagingBridge? = null
private fun clearSyncCallback() {
syncCallbackBinder?.let { binder ->
runCatching {
binder.unlinkToDeath(syncCallbackDeathRecipient, 0)
}
}
syncCallbackBinder = null
syncCallback = null
}
override fun onDestroy() {
clearSyncCallback()
if (::remoteSideContext.isInitialized) {
remoteSideContext.bridgeService = null
}
@@ -47,8 +64,10 @@ class BridgeService : Service() {
}
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
val callback = syncCallback ?: return
runCatching {
if (!syncCallback.asBinder().pingBinder()) {
if (!callback.asBinder().pingBinder()) {
clearSyncCallback()
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
@@ -57,11 +76,11 @@ class BridgeService : Service() {
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
callback.syncFriend(id)
}
SocialScope.GROUP -> {
if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
callback.syncGroup(id)
}
} ?: run {
remoteSideContext.log.warn("Failed to sync $scope $id")
@@ -77,6 +96,11 @@ class BridgeService : Service() {
}
}
}.onFailure {
if (it is RemoteException) {
clearSyncCallback()
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return@onFailure
}
remoteSideContext.log.error("Failed to sync $scope $id", it)
}
}
@@ -162,7 +186,16 @@ class BridgeService : Service() {
}
override fun sync(callback: SyncCallback) {
clearSyncCallback()
syncCallback = callback
syncCallbackBinder = callback.asBinder().also { binder ->
runCatching {
binder.linkToDeath(syncCallbackDeathRecipient, 0)
}.onFailure {
clearSyncCallback()
throw it
}
}
measureTimeMillis {
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
triggerScopeSync(SocialScope.FRIEND, friendId, true)

View File

@@ -794,7 +794,9 @@ class Navigation(
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
children.forEach { child ->
composable(child.routeInfo.id) { child.content.invoke(it) }
}
route.customComposables.invoke(this)
}
}

View File

@@ -0,0 +1,51 @@
package me.eternal.purrfectsnap.ui.manager
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@Composable
fun rememberRouteScrollState(key: String): ScrollState {
val scrollState = remember(key) {
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
}
LaunchedEffect(key, scrollState.value) {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
DisposableEffect(key, scrollState) {
onDispose {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
}
return scrollState
}
@Composable
fun rememberRouteLazyListState(key: String): LazyListState {
val savedState = RouteStateCache.lazyListOffsets[key]
val listState = remember(key) {
LazyListState(
firstVisibleItemIndex = savedState?.first ?: 0,
firstVisibleItemScrollOffset = savedState?.second ?: 0
)
}
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
DisposableEffect(key, listState) {
onDispose {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
}
return listState
}
private object RouteStateCache {
val scrollOffsets = mutableMapOf<String, Int>()
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
}

View File

@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
object UpdateDownloader {
private const val TAG = "UpdateDownloader"
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private val fallbackHttpClient by lazy { OkHttpClient() }
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
@@ -84,6 +87,132 @@ object UpdateDownloader {
return downloadedFile
}
private fun scheduleReset(scope: CoroutineScope) {
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
downloadProgress.value = 0f
}
}
private fun installDownloadedFile(
remoteContext: RemoteSideContext,
downloadedFile: File,
scope: CoroutineScope
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.COMPLETED
runCatching {
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
scheduleReset(scope)
}
private fun failDownload(
remoteContext: RemoteSideContext,
scope: CoroutineScope,
errorMessage: String,
throwable: Throwable? = null
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to errorMessage),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
scheduleReset(scope)
}
private fun startHttpFallbackDownload(
remoteContext: RemoteSideContext,
downloadUrl: String,
filePath: String,
scope: CoroutineScope
) {
val partialFile = File("$filePath.part")
val outputFile = File(filePath)
scope.launch(Dispatchers.IO) {
runCatching {
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
partialFile.parentFile?.mkdirs()
if (partialFile.exists()) partialFile.delete()
if (outputFile.exists()) outputFile.delete()
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "PurrfectSnap-Updater")
.build()
fallbackHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("HTTP_${response.code}")
}
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
val contentLength = body.contentLength()
var downloadedBytes = 0L
body.byteStream().use { input ->
partialFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
}
}
}
}
}
if (!partialFile.renameTo(outputFile)) {
partialFile.copyTo(outputFile, overwrite = true)
partialFile.delete()
}
installDownloadedFile(remoteContext, outputFile, scope)
}.onFailure {
runCatching { partialFile.delete() }
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
}
}
}
fun downloadAndInstall(
remoteContext: RemoteSideContext,
downloadUrl: String,
@@ -100,6 +229,7 @@ object UpdateDownloader {
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
var fallbackAttempted = false
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
@@ -116,60 +246,21 @@ object UpdateDownloader {
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
runCatching {
val downloadedFile = File(download.file)
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
installDownloadedFile(remoteContext, File(download.file), scope)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to error.toString()),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
?: remoteContext.log.error("Update download failed: $error", TAG)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
fallbackAttempted = true
downloadState.value = DownloadState.DOWNLOADING
downloadProgress.value = 0f
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
return
}
failDownload(remoteContext, scope, error.toString(), throwable)
}
}
fetch.addListener(listener!!)

View File

@@ -301,12 +301,13 @@ class TasksRootSection : Routes.Route() {
)
Column {
Text(
text = context.translation["delete_files_option"],
text = context.translation["manager.sections.tasks.delete_files_option"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
text = context.translation["manager.sections.tasks.delete_files_option_hint"]
?: "Also remove downloaded files",
color = PurrfectPalette.textSecondary,
style = MaterialTheme.typography.bodySmall
)

View File

@@ -91,6 +91,7 @@ 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.rememberRouteLazyListState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.*
import org.json.JSONArray
@@ -254,7 +255,10 @@ class FeaturesRootSection : Routes.Route() {
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
Container(context.config.root)
Container(
configContainer = context.config.root,
stateKey = "${routeInfo.id}:root"
)
}
override val customComposables: NavGraphBuilder.() -> Unit = {
@@ -277,6 +281,7 @@ class FeaturesRootSection : Routes.Route() {
val containerSubtitle = translation[it.key.propertyDescription()]
Container(
configContainer = it.value.get() as ConfigContainer,
stateKey = "${routeInfo.id}:container:$containerName",
sectionTitle = containerTitle,
sectionSubtitle = containerSubtitle,
onBack = { routes.navController.popBackStack() }
@@ -297,6 +302,7 @@ class FeaturesRootSection : Routes.Route() {
PropertiesView(
properties = properties,
stateKey = "${routeInfo.id}:search:$keyword",
isSearchResults = true,
searchKeyword = keyword,
enableGlobalSearch = true,
@@ -1568,6 +1574,7 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun PropertiesView(
properties: List<PropertyPair<*>>,
stateKey: String,
isSearchResults: Boolean = false,
activeSectionTitle: String? = null,
activeSectionSubtitle: String? = null,
@@ -1577,7 +1584,7 @@ class FeaturesRootSection : Routes.Route() {
) {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(96.dp) }
val listState = rememberLazyListState()
val listState = rememberRouteLazyListState(stateKey)
val sharedSearchHistory = remember { mutableStateListOf<String>().apply { addAll(loadSearchHistory()) } }
var liveSearchQuery by rememberSaveable { mutableStateOf(searchKeyword.orEmpty()) }
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
@@ -1682,6 +1689,7 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun Container(
configContainer: ConfigContainer,
stateKey: String,
sectionTitle: String? = null,
sectionSubtitle: String? = null,
searchKeyword: String? = null,
@@ -1693,6 +1701,7 @@ class FeaturesRootSection : Routes.Route() {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
}
},
stateKey = stateKey,
activeSectionTitle = sectionTitle,
activeSectionSubtitle = sectionSubtitle,
searchKeyword = searchKeyword,

View File

@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)

View File

@@ -1,13 +1,20 @@
package me.eternal.purrfectsnap.ui.manager.pages.location
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
alertDialogs.DefaultDialogCard {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
val fieldColors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedLabelColor = PurrfectPalette.textSecondary,
unfocusedLabelColor = PurrfectPalette.textSecondary,
cursorColor = PurrfectPalette.glowSecondary,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
Surface(
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp
) {
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(
Column(
modifier = Modifier
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) }
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) }
)
OutlinedTextField(
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) }
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
Color(0xFF1A143A).copy(alpha = 0.94f)
)
),
RoundedCornerShape(24.dp)
)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
Text(
text = translation["save_coordinates_dialog_title"],
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
horizontalArrangement = Arrangement.End
) {
Text(translation["save_dialog_button"])
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
contentColor = Color.White,
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
disabledContentColor = Color.White.copy(alpha = 0.6f)
)
) {
Text(translation["save_dialog_button"])
}
}
}
}
}
}
}

View File

@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -221,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
}
@Composable
private fun ThemedEditLocationButton(onClick: () -> Unit) {
private fun CoordinateActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
accent: Color,
onClick: () -> Unit
) {
FilledIconButton(
modifier = Modifier.size(40.dp),
onClick = onClick,
modifier = Modifier.size(42.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
),
onClick = onClick
containerColor = accent.copy(alpha = 0.22f),
contentColor = Color.White
)
) {
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
Icon(icon, contentDescription = description)
}
}
@@ -674,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
color = PurrfectPalette.textSecondary
)
}
FilledIconButton(onClick = {
CoordinateActionButton(
icon = Icons.Default.Edit,
description = translation["edit_icon_description"],
accent = PurrfectPalette.glowPrimary
) {
showEditDialog = true
}) {
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
}
FilledIconButton(onClick = {
CoordinateActionButton(
icon = Icons.Default.DeleteOutline,
description = translation["delete_icon_description"],
accent = PurrfectPalette.glowSecondary
) {
showDeleteDialog = true
}) {
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
}
}
}

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.5").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("275").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("278").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,20 @@
## v1.3.8
- Fix: Download profile picture button shape
- New: Auto Patcher(Jingmatrix Lspatch) updated to v0.8
- Fix: Video playback rate slider mobility
- New: Redesign Convert Message dialog
- Fix: Media File Picker issues
- Fix: REQUEST_NOT_SUCCESSFUL issues while updating PurrfectSnap for some devices
## v1.3.6
- Fix: Crash issues for some devices
- Fix: Scroll state
- New: Redesign profile picture downloader & spotlight comments username dialog
- New: Change the profile picture download button to an icon
- New: Redesign playback slider & saved coordinates buttons
- Fix: Add missing translations(en_US only for now, all languages to be supported in the next update)
- Fix: Deadosobjectexception
## v1.3.5
- New: Added support for Snap versions upto v13.81(server side update, requires v1.3.2 and above)
- New: Migration from libxposed API to YukiHook

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"delete_files_option": "Also delete files",
"delete_files_option_hint": "Permanently remove the original files from storage",
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
@@ -2994,9 +2995,20 @@
"profile_picture_downloader": {
"button": "Download Profile Picture",
"title": "Profile Picture Downloader",
"subtitle": "Choose which profile image to save",
"empty_state": "No profile pictures available. Please wait for the profile to load.",
"download_hint": "Tap to download",
"avatar_option": "Avatar",
"background_option": "Background"
},
"spotlight_comments_username_dialog": {
"title": "User Information",
"subtitle": "Spotlight comment profile details",
"username_label": "Username",
"display_name_label": "Display Name",
"user_id_label": "User ID",
"not_available": "Not available"
},
"call_start_confirmation": {
"dialog_title": "Start Call",
"dialog_message": "Are you sure you want to start a call?"
@@ -3235,6 +3247,9 @@
"select_date_first": "Please select a date",
"invalid_time": "Please select a future time"
},
"convert_message_dialog": {
"subtitle": "Choose how this message should be reshaped locally."
},
"spotlight_creator_info": {
"title": "Creator Info",
"close": "Close",
@@ -3506,6 +3521,8 @@
"cancel": "Cancel",
"documentation": "Documentation"
},
"include_saved_locations": "Include Saved Locations",
"include_saved_locations_description": "Export your saved location coordinates",
"common": {
"cancel": "Cancel",
"close": "Close",

View File

@@ -52,6 +52,7 @@ class PurrfectSnap {
private var isBridgeInitialized = false
private var android9ValdiBindDisabled = false
private var android9ValdiBindDisableLogged = false
private val nativeLateInitTriggered = java.util.concurrent.atomic.AtomicBoolean(false)
private fun hookMainActivity(methodName: String, stage: HookStage = HookStage.AFTER, block: Activity.(param: HookAdapter) -> Unit) {
Activity::class.java.hook(methodName, stage, { isBridgeInitialized }) { param ->
@@ -391,6 +392,7 @@ class PurrfectSnap {
lateinit var unhook: () -> Unit
hook(HookStage.AFTER) { param ->
if (param.arg<String>(1) != "client") return@hook
if (!nativeLateInitTriggered.compareAndSet(false, true)) return@hook
unhook()
lateInit()
}.also { unhook = { it.unhook() } }

View File

@@ -1,16 +1,60 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.annotation.SuppressLint
import android.widget.Button
import android.widget.RelativeLayout
import android.app.Activity
import android.content.res.ColorStateList
import android.graphics.drawable.GradientDrawable
import android.util.TypedValue
import android.widget.ImageView
import android.widget.ImageButton
import android.widget.FrameLayout
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.BorderStroke
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.util.ktx.isDarkTheme
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
private companion object {
const val DOWNLOAD_BUTTON_TAG = "profile_picture_download_button"
}
@SuppressLint("SetTextI18n")
override fun init() {
if (!context.config.downloader.downloadProfilePictures.get()) return
@@ -29,46 +73,130 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name !in profileViewClasses) return@subscribe
val activity = context.mainActivity ?: return@subscribe
val rootContent = activity.findViewById<FrameLayout>(android.R.id.content) ?: return@subscribe
val buttonText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
if ((0 until event.parent.childCount).any {
val child = event.parent.getChildAt(it)
child is Button && (child as Button).text == buttonText
}) return@subscribe
rootContent.findViewWithTag<View>(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) }
event.parent.addView(Button(event.parent.context).apply {
text = buttonText
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
val button = ImageButton(activity).apply {
val density = resources.displayMetrics.density
val buttonSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 40f, resources.displayMetrics).toInt()
val iconPadding = (8 * density).toInt()
val darkTheme = context.isDarkTheme()
tag = DOWNLOAD_BUTTON_TAG
contentDescription = buttonText
scaleType = ImageView.ScaleType.CENTER
setImageResource(android.R.drawable.stat_sys_download)
imageTintList = ColorStateList.valueOf(
if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.parseColor("#151A1A")
)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(
if (darkTheme) android.graphics.Color.parseColor("#1D1D1D")
else android.graphics.Color.WHITE
)
}
setPadding(iconPadding, iconPadding, iconPadding, iconPadding)
minimumWidth = 0
minimumHeight = 0
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
stateListAnimator = null
}
layoutParams = FrameLayout.LayoutParams(buttonSize, buttonSize)
setOnClickListener {
ViewAppearanceHelper.newAlertDialogBuilder(
this@ProfilePictureDownloader.context.mainActivity!!
).apply {
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
val choices = mutableMapOf<String, String>()
backgroundUrl?.let { choices["background_option"] = it }
avatarUrl?.let { choices["avatar_option"] = it }
if (choices.isEmpty()) {
setMessage("No profile pictures available. Please wait for the profile to load.")
setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
} else {
setItems(choices.keys.map {
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
}.toTypedArray()) { _, which ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
choices.values.elementAt(which),
friendUsername ?: "unknown"
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
}
val choices = buildList {
backgroundUrl?.let {
add(
ProfilePictureChoice(
key = "background_option",
url = it,
iconType = ProfilePictureChoiceIcon.BACKGROUND
)
)
}
}.show()
avatarUrl?.let {
add(
ProfilePictureChoice(
key = "avatar_option",
url = it,
iconType = ProfilePictureChoiceIcon.AVATAR
)
)
}
}
createComposeAlertDialog(
this@ProfilePictureDownloader.context.mainActivity!!,
content = { alertDialog ->
ProfilePictureDialog(
title = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"],
subtitle = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.subtitle"],
emptyText = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.empty_state"],
downloadHint = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.download_hint"],
closeLabel = this@ProfilePictureDownloader.context.translation["common.close"],
choices = choices,
optionLabel = { key ->
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$key"]
},
onDownload = { selectedUrl ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
selectedUrl,
friendUsername ?: "unknown"
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
alertDialog.dismiss()
},
onDismiss = { alertDialog.dismiss() }
)
}
).show()
}
}
val leftOffsetPx = (8 * activity.resources.displayMetrics.density).toInt()
val topOffsetPx = 236
val anchorView = event.view
val positionUpdater = ViewTreeObserver.OnPreDrawListener {
updateOverlayButtonPosition(
activity = activity,
anchorView = anchorView,
button = button,
leftOffsetPx = leftOffsetPx,
topOffsetPx = topOffsetPx
)
true
}
anchorView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) {
if (rootContent.viewTreeObserver.isAlive) {
rootContent.viewTreeObserver.removeOnPreDrawListener(positionUpdater)
}
rootContent.findViewWithTag<View>(DOWNLOAD_BUTTON_TAG)?.let { rootContent.removeView(it) }
v.removeOnAttachStateChangeListener(this)
}
})
rootContent.addView(button)
rootContent.viewTreeObserver.addOnPreDrawListener(positionUpdater)
rootContent.post {
updateOverlayButtonPosition(
activity = activity,
anchorView = anchorView,
button = button,
leftOffsetPx = leftOffsetPx,
topOffsetPx = topOffsetPx
)
button.bringToFront()
}
}
@@ -99,4 +227,233 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
}
}
}
@Composable
private fun ProfilePictureDialog(
title: String,
subtitle: String,
emptyText: String,
downloadHint: String,
closeLabel: String,
choices: List<ProfilePictureChoice>,
optionLabel: (String) -> String,
onDownload: (String) -> Unit,
onDismiss: () -> Unit
) {
val shape = remember { RoundedCornerShape(24.dp) }
val overlayBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.95f),
Color(0xFF1A143A).copy(alpha = 0.92f)
)
)
}
val accentBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.42f),
Color(0xFF5FD8FF).copy(alpha = 0.34f)
)
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = shape,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2452).copy(alpha = 0.95f))
) {
Box(
modifier = Modifier
.background(overlayBrush, shape)
.padding(20.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(accentBrush, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(30.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Center
)
}
if (choices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Color.White.copy(alpha = 0.06f),
RoundedCornerShape(18.dp)
)
.padding(horizontal = 18.dp, vertical = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = emptyText,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Center
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
choices.forEach { choice ->
ProfilePictureOptionCard(
title = optionLabel(choice.key),
iconType = choice.iconType,
downloadHint = downloadHint,
onClick = { onDownload(choice.url) }
)
}
}
}
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8C7BFF).copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(
text = closeLabel,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
@Composable
private fun ProfilePictureOptionCard(
title: String,
iconType: ProfilePictureChoiceIcon,
downloadHint: String,
onClick: () -> Unit
) {
val icon = when (iconType) {
ProfilePictureChoiceIcon.AVATAR -> Icons.Default.Person
ProfilePictureChoiceIcon.BACKGROUND -> Icons.Default.Image
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.18f),
Color(0xFF5FD8FF).copy(alpha = 0.1f)
)
),
RoundedCornerShape(18.dp)
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(42.dp)
.background(Color.White.copy(alpha = 0.1f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
Text(
text = downloadHint,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFD9D3FF)
)
}
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
tint = Color(0xFF5FD8FF)
)
}
}
private data class ProfilePictureChoice(
val key: String,
val url: String,
val iconType: ProfilePictureChoiceIcon
)
private enum class ProfilePictureChoiceIcon {
AVATAR,
BACKGROUND
}
private fun updateOverlayButtonPosition(
activity: Activity,
anchorView: View,
button: View,
leftOffsetPx: Int,
topOffsetPx: Int
) {
if (!anchorView.isAttachedToWindow || !button.isAttachedToWindow) return
val rootContent = activity.findViewById<FrameLayout>(android.R.id.content) ?: return
val rootLocation = IntArray(2)
val anchorLocation = IntArray(2)
rootContent.getLocationOnScreen(rootLocation)
anchorView.getLocationOnScreen(anchorLocation)
button.x = (anchorLocation[0] - rootLocation[0] + leftOffsetPx).toFloat()
button.y = (anchorLocation[1] - rootLocation[1] + topOffsetPx).toFloat()
}
}

View File

@@ -1,12 +1,45 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import androidx.compose.foundation.BorderStroke
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cached
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
@@ -26,8 +59,8 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
fun convertMessageInterface(messageInstance: Message) {
val actions = mutableMapOf<String, (Message) -> Unit>()
actions[context.translation["button.restore_original"]] = actions@{ message ->
val actions = mutableListOf<Pair<String, (Message) -> Unit>>()
actions += context.translation["button.restore_original"] to actions@{ message ->
val descriptor = message.messageDescriptor ?: return@actions
messageCache.remove(descriptor.messageId!!)
context.feature(Messaging::class).conversationManager?.fetchMessage(
@@ -41,7 +74,7 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
val contentType = messageInstance.messageContent?.contentType
if (contentType == ContentType.SNAP) {
actions[context.translation["button.convert_external_media"]] = convert@{ message ->
actions += context.translation["button.convert_external_media"] to convert@{ message ->
val snapMessageContent = ProtoReader(message.messageContent!!.content!!).followPath(11)
?.getBuffer() ?: return@convert
message.messageContent!!.content = ProtoWriter().apply {
@@ -53,13 +86,26 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply {
setItems(actions.keys.toTypedArray()) { _, which ->
actions.values.elementAt(which).invoke(messageInstance)
}
setPositiveButton(this@ConvertMessageLocally.context.translation["button.cancel"]) { dialog, _ ->
dialog.dismiss()
}
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
ConvertMessageDialog(
title = context.translation["chat_action_menu.convert_message"],
subtitle = context.translation["convert_message_dialog.subtitle"],
closeLabel = context.translation["button.cancel"],
actions = actions.map { (label, _) ->
ConvertMessageAction(
label = label,
icon = when (label) {
context.translation["button.restore_original"] -> Icons.Default.Restore
else -> Icons.Default.Cached
}
)
},
onSelect = { index ->
actions.getOrNull(index)?.second?.invoke(messageInstance)
alertDialog.dismiss()
},
onDismiss = { alertDialog.dismiss() }
)
}.show()
}
@@ -72,4 +118,162 @@ class ConvertMessageLocally : Feature("Convert Message Edit") {
}
}
}
}
@Composable
private fun ConvertMessageDialog(
title: String,
subtitle: String,
closeLabel: String,
actions: List<ConvertMessageAction>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit
) {
val shape = remember { RoundedCornerShape(24.dp) }
val overlayBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.95f),
Color(0xFF1A143A).copy(alpha = 0.92f)
)
)
}
val accentBrush = remember {
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.42f),
Color(0xFF5FD8FF).copy(alpha = 0.34f)
)
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = shape,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2452).copy(alpha = 0.95f))
) {
Box(
modifier = Modifier
.background(overlayBrush, shape)
.padding(20.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(accentBrush, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.EditNote,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(30.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Center
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
actions.forEachIndexed { index, action ->
ConvertMessageOptionCard(
label = action.label,
icon = action.icon,
onClick = { onSelect(index) }
)
}
}
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8C7BFF).copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(
text = closeLabel,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
@Composable
private fun ConvertMessageOptionCard(
label: String,
icon: ImageVector,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.18f),
Color(0xFF5FD8FF).copy(alpha = 0.1f)
)
),
RoundedCornerShape(18.dp)
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(42.dp)
.background(Color.White.copy(alpha = 0.1f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
private data class ConvertMessageAction(
val label: String,
val icon: ImageVector
)
}

View File

@@ -6,10 +6,11 @@ import android.content.ContentResolver
import android.content.Intent
import android.database.Cursor
import android.database.CursorWrapper
import android.media.MediaPlayer
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -39,12 +40,12 @@ import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getTypeArguments
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
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.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -57,6 +58,35 @@ class MediaFilePicker : Feature("Media File Picker") {
var lastMediaDuration: Long? = null
private set
private fun extractMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return runCatching {
retriever.setDataSource(context.androidContext, uri)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
}.getOrNull().also {
runCatching { retriever.release() }
}
}
private fun resolveInputExtension(uri: Uri, mimeType: String?): String {
val extensionFromMime = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }?.lowercase()
val extensionFromUri = MimeTypeMap.getFileExtensionFromUrl(uri.toString()).takeIf { !it.isNullOrBlank() }?.lowercase()
return when (extensionFromMime ?: extensionFromUri ?: mimeType) {
"video/mp4", "audio/mp4", "application/mp4", "mp4", "m4v" -> "mp4"
"video/quicktime", "mov", "qt" -> "mov"
"video/webm", "webm" -> "webm"
"video/x-matroska", "video/mkv", "mkv" -> "mkv"
"video/avi", "video/x-msvideo", "avi" -> "avi"
"audio/mpeg", "audio/mp3", "mp3" -> "mp3"
"audio/aac", "aac" -> "aac"
"audio/ogg", "audio/opus", "opus", "ogg" -> "opus"
"audio/wav", "audio/x-wav", "wav" -> "wav"
"audio/mp4a-latm", "audio/x-m4a", "m4a" -> "m4a"
else -> FileType.fromString(extensionFromMime ?: extensionFromUri).fileExtension ?: "mp4"
}
}
@SuppressLint("Recycle")
override fun init() {
if (!context.config.experimental.mediaFilePicker.get()) return
@@ -154,7 +184,7 @@ class MediaFilePicker : Feature("Media File Picker") {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", 0.0)
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
@@ -175,19 +205,27 @@ class MediaFilePicker : Feature("Media File Picker") {
fun startConversion(audioOnly: Boolean) {
context.coroutineScope.launch {
lastMediaDuration = MediaPlayer().run {
setDataSource(context.androidContext, event.intent.data!!)
prepare()
duration.toLong().also {
release()
}
val pickedUri = event.intent?.data ?: run {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "No media was selected.")
return@launch
}
val mimeType = context.androidContext.contentResolver.getType(pickedUri)
val inputExtension = resolveInputExtension(pickedUri, mimeType)
val outputExtension = if (audioOnly || mimeType?.startsWith("audio/") == true) "m4a" else "mp4"
lastMediaDuration = extractMediaDuration(pickedUri)
context.inAppOverlay.showStatusToast(Icons.Default.Crop, "Converting media...", durationMs = 3000)
val pickedFileDescriptor = context.androidContext.contentResolver.openFileDescriptor(pickedUri, "r")
if (pickedFileDescriptor == null) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to open selected media.")
return@launch
}
val pfd = context.bridgeClient.convertMedia(
context.androidContext.contentResolver.openFileDescriptor(event.intent.data!!, "r")!!,
"m4a",
"m4a",
pickedFileDescriptor,
inputExtension,
outputExtension,
"aac",
if (!audioOnly) "libx264" else null
)
@@ -210,14 +248,15 @@ class MediaFilePicker : Feature("Media File Picker") {
}
}
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
val pickedUri = event.intent?.data ?: return@subscribe
val isAudio = context.androidContext.contentResolver.getType(pickedUri)?.startsWith("audio/") == true
if (isAudio || context.config.messaging.galleryMediaSendOverride.mode.getNullable() == null) {
startConversion(isAudio)
return@subscribe
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!)
android.app.AlertDialog.Builder(context.mainActivity!!)
.setTitle("Convert video file")
.setItems(arrayOf("Send as video/audio", "Send as audio only")) { _, which ->
startConversion(which == 1)
@@ -313,4 +352,5 @@ class MediaFilePicker : Feature("Media File Picker") {
}
}
}
}

View File

@@ -1,29 +1,59 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableString
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.util.EvictingMap
import java.text.SimpleDateFormat
import java.util.*
import java.util.Locale
class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") {
private val usernameCache = EvictingMap<String, String>(150)
private val dialogTranslation by lazy { context.translation.getCategory("spotlight_comments_username_dialog") }
@SuppressLint("SetTextI18n")
override fun init() {
@@ -96,39 +126,182 @@ class SpotlightCommentsUsername : Feature("SpotlightCommentsUsername") {
}.getOrNull()
withContext(Dispatchers.Main) {
val builder = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
builder.setTitle("User Information")
val userInfoText = buildString {
append("Username: $username\n")
userInfo?.let { info ->
append("User ID: ${userId}\n")
append("Display Name: ${info.displayName ?: "Not available"}\n")
} ?: append("Unable to retrieve additional information")
}
builder.setMessage(userInfoText)
builder.setPositiveButton("OK") { dialog: DialogInterface, _: Int ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
// Make text selectable
dialog.findViewById<TextView>(android.R.id.message)?.let { messageView ->
messageView.setTextIsSelectable(true)
messageView.typeface = Typeface.MONOSPACE
createComposeAlertDialog(
context.mainActivity!!,
content = { alertDialog ->
UserInfoDialog(
username = username,
userId = userId,
displayName = userInfo?.displayName,
title = dialogTranslation["title"],
subtitle = dialogTranslation["subtitle"],
usernameLabel = dialogTranslation["username_label"],
displayNameLabel = dialogTranslation["display_name_label"],
userIdLabel = dialogTranslation["user_id_label"],
unavailableLabel = dialogTranslation["not_available"],
closeLabel = context.translation["common.close"],
onDismiss = { alertDialog.dismiss() }
)
}
).show()
}
}
}
@Composable
private fun UserInfoDialog(
username: String,
userId: String,
displayName: String?,
title: String,
subtitle: String,
usernameLabel: String,
displayNameLabel: String,
userIdLabel: String,
unavailableLabel: String,
closeLabel: String,
onDismiss: () -> Unit
) {
val shape = remember { RoundedCornerShape(24.dp) }
val overlayBrush = remember {
Brush.linearGradient(
listOf(
ComposeColor(0xFF2A2452).copy(alpha = 0.95f),
ComposeColor(0xFF1A143A).copy(alpha = 0.92f)
)
)
}
val accentBrush = remember {
Brush.linearGradient(
listOf(
ComposeColor(0xFF8C7BFF).copy(alpha = 0.42f),
ComposeColor(0xFF5FD8FF).copy(alpha = 0.34f)
)
)
}
val rows = remember(username, userId, displayName) {
listOf(
usernameLabel to username,
displayNameLabel to (displayName?.takeIf { it.isNotBlank() } ?: unavailableLabel),
userIdLabel to userId
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = shape,
border = BorderStroke(1.dp, ComposeColor.White.copy(alpha = 0.12f)),
colors = CardDefaults.cardColors(containerColor = ComposeColor(0xFF2A2452).copy(alpha = 0.95f))
) {
Box(
modifier = Modifier
.background(overlayBrush, shape)
.padding(20.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.background(accentBrush, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = ComposeColor.White,
modifier = Modifier.size(30.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
color = ComposeColor.White,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ComposeColor(0xFFD9D3FF),
textAlign = TextAlign.Center
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(
ComposeColor.White.copy(alpha = 0.06f),
RoundedCornerShape(18.dp)
)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
rows.forEachIndexed { index, (label, value) ->
UserInfoRow(label = label, value = value)
if (index != rows.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 2.dp),
color = ComposeColor.White.copy(alpha = 0.08f)
)
}
}
}
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = ComposeColor(0xFF8C7BFF).copy(alpha = 0.34f),
contentColor = ComposeColor.White
)
) {
Text(
text = closeLabel,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
private fun formatDate(timestamp: Long): String {
return if (timestamp > 0) {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp))
} else {
"Not available"
@Composable
private fun UserInfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Text(
text = label,
color = ComposeColor(0xFFD9D3FF),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(96.dp)
)
SelectionContainer(
modifier = Modifier.weight(1f)
) {
Text(
text = value,
color = ComposeColor.White,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End
)
}
}
}
}

View File

@@ -1,26 +1,46 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.TextView
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.size
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Slider
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SlowMotionVideo
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.use
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
@@ -137,30 +157,144 @@ class OperaContextActionMenu : AbstractMenu() {
val operaViewerParamsOverride = context.feature(OperaViewerParamsOverride::class)
linearLayout.addView(createComposeView(view.context) {
val glowPrimary = Color(0xFF8C7BFF)
val glowSecondary = Color(0xFF5FD8FF)
val cardShape = RoundedCornerShape(22.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
var value by remember { mutableFloatStateOf(operaViewerParamsOverride.currentPlaybackRate) }
Slider(
value = value,
onValueChange = {
value = it
operaViewerParamsOverride.currentPlaybackRate = it
},
valueRange = 0.1F..4.0F,
steps = 0,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "x" + value.toString().take(4),
color = remember {
Color(context.userInterface.colorPrimary)
},
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Card(
shape = cardShape,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
glowPrimary.copy(alpha = 0.45f),
glowSecondary.copy(alpha = 0.35f)
)
)
),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF2A2452).copy(alpha = 0.94f)
)
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
listOf(
Color(0xFF2A2452).copy(alpha = 0.95f),
Color(0xFF1A143A).copy(alpha = 0.92f)
)
),
cardShape
)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(42.dp)
.background(
Brush.linearGradient(
listOf(
glowPrimary.copy(alpha = 0.35f),
glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
)
) {
Icon(
imageVector = Icons.Default.SlowMotionVideo,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.align(androidx.compose.ui.Alignment.Center)
.size(22.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Playback Rate",
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
Text(
text = "x" + String.format("%.2f", value),
color = Color(0xFFD9D3FF),
textAlign = TextAlign.Start
)
}
}
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { androidContext ->
SeekBar(androidContext).apply {
max = 390
progress = ((value - 0.1f) * 100).toInt().coerceIn(0, max)
thumbTintList = ColorStateList.valueOf(Color.White.toArgb())
progressTintList = ColorStateList.valueOf(glowSecondary.toArgb())
progressBackgroundTintList = ColorStateList.valueOf(Color.White.copy(alpha = 0.16f).toArgb())
splitTrack = false
setOnTouchListener { seekBar, motionEvent ->
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> seekBar.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> seekBar.parent?.requestDisallowInterceptTouchEvent(false)
}
false
}
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
val playbackRate = (0.1f + (progress / 100f)).coerceIn(0.1f, 4.0f)
value = playbackRate
operaViewerParamsOverride.currentPlaybackRate = playbackRate
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
seekBar?.parent?.requestDisallowInterceptTouchEvent(true)
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
seekBar?.parent?.requestDisallowInterceptTouchEvent(false)
}
})
}
},
update = { seekBar ->
val targetProgress = ((value - 0.1f) * 100).toInt().coerceIn(0, seekBar.max)
if (seekBar.progress != targetProgress) {
seekBar.progress = targetProgress
}
}
)
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "0.1x",
color = Color(0xFFD9D3FF),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "4.0x",
color = Color(0xFFD9D3FF),
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
}
}
}
}
}.apply {
layoutParams = ViewGroup.LayoutParams(

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.3.5
APP_VERSION_CODE=275
APP_VERSION_NAME=1.3.8
APP_VERSION_CODE=278
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

View File

@@ -15,7 +15,7 @@ pub static CLIENT_MODULE: Lazy<MappedLib> = Lazy::new(|| {
client_module = MappedLib::new("split_config.arm".into());
if let Err(error) = client_module.search() {
panic!("Unable to find split_config.arm: {}", error);
error!("Unable to find split_config.arm: {}", error);
}
}
@@ -46,4 +46,4 @@ pub fn attach_jni_env(block: impl FnOnce(&mut jni::JNIEnv)) {
let mut env: jni::AttachGuard = jvm.attach_current_thread().expect("Failed to attach to current thread");
block(&mut env);
}
}

View File

@@ -43,7 +43,7 @@ macro_rules! dobby_hook_sym {
crate::dobby_hook!(hook_symbol, $hook);
debug!("hooked symbol: {}", $sym);
} else {
panic!("Failed to resolve symbol: {}", $sym);
error!("Failed to resolve symbol: {}", $sym);
}
};
}

View File

@@ -155,9 +155,15 @@ fn setChecksums(mut env: JNIEnv, _class: JClass, checksums_json: JString) {
fn pre_init(_env: JNIEnv, _class: JObject) {
debug!("Pre init");
linker_hook::init();
custom_font_hook::init();
fstat_hook::init();
for (name, init) in [
("linker_hook", linker_hook::init as fn()),
("custom_font_hook", custom_font_hook::init as fn()),
("fstat_hook", fstat_hook::init as fn()),
] {
if let Err(error) = std::panic::catch_unwind(init) {
error!("{} init failed: {:?}", name, error);
}
}
}
fn init(mut env: JNIEnv, _class: JObject, signature_cache: JString) -> jstring {
@@ -186,23 +192,29 @@ fn init(mut env: JNIEnv, _class: JObject, signature_cache: JString) -> jstring {
let mut threads: Vec<std::thread::JoinHandle<()>> = Vec::new();
macro_rules! async_init {
($($f:expr),*) => {
($(($name:expr, $f:expr)),* $(,)?) => {
$(
threads.push(std::thread::spawn(move || {
$f;
if let Err(error) = std::panic::catch_unwind(|| { $f; }) {
error!("{} init failed: {:?}", $name, error);
}
}));
)*
};
}
async_init!(
duplex_hook::init(),
unary_call_hook::init(),
valdi_hook::init(),
sqlite_hook::init()
("duplex_hook", duplex_hook::init()),
("unary_call_hook", unary_call_hook::init()),
("valdi_hook", valdi_hook::init()),
("sqlite_hook", sqlite_hook::init())
);
threads.into_iter().for_each(|t| t.join().unwrap());
threads.into_iter().for_each(|t| {
if let Err(error) = t.join() {
error!("native init worker panicked: {:?}", error);
}
});
info!("native init took {:?}", start_time.elapsed());

View File

@@ -19,11 +19,13 @@ def_hook!(
let content = content.into_boxed_slice();
if libc::write(memfd, content.as_ptr() as *const c_void, content.len() as libc::size_t) == -1 {
panic!("failed to write to memfd");
error!("failed to write to memfd");
return linker_openat_original.unwrap()(dir_fd, pathname, flags, mode);
}
if libc::lseek(memfd, 0, libc::SEEK_SET) == -1 {
panic!("failed to seek memfd");
error!("failed to seek memfd");
return linker_openat_original.unwrap()(dir_fd, pathname, flags, mode);
}
std::mem::forget(content);