Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26077a8c87 | ||
|
|
28982aa655 | ||
|
|
89748cd261 | ||
|
|
97797127bd | ||
|
|
23d8ff0370 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
- [O‑MVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>()
|
||||
}
|
||||
@@ -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!!)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() } }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user