9 Commits

Author SHA1 Message Date
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
ΞTΞRNAL
565a8b8287 v1.4.9 2026-03-21 03:48:43 +05:30
ΞTΞRNAL
1c612ebc8e fix: force AMOLED theme for newer snap versions 2026-03-21 02:09:33 +05:30
ΞTΞRNAL
8db363a8f0 add missing changelog 2026-03-20 22:19:18 +05:30
ΞTΞRNAL
4fab5cc4ab fix(PR): Crash when using the theme button in android 11 & 12 by Kaladin
fix: Crash when using the theme button in android 11 & 12.
2026-03-20 22:18:12 +05:30
ΞTΞRNAL
a7a15702f3 v1.4.8 2026-03-20 22:16:37 +05:30
DarkKnight2122
04aaefc748 fix: optimize theme reveal fallbacks for Android 11 and 12 2026-03-20 19:22:55 +05:30
ΞTΞRNAL
58c4be44f2 v1.4.6 2026-03-20 05:05:25 +05:30
19 changed files with 873 additions and 363 deletions

View File

@@ -214,13 +214,20 @@ class MainActivity : ComponentActivity() {
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
// Theme Reveal Overlay (Android 13+ only for stability)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
} else {
// Instantly clear reveal state on older versions
navigation.themeRevealState.pendingReveal?.let {
navigation.themeRevealState.clearReveal()
}
}
Box(

View File

@@ -1273,11 +1273,65 @@ class FeaturesRootSection : Routes.Route() {
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = !showSearchBar
}) {
Icon(imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search, contentDescription = null, tint = Color.White)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showSearchBar = !showSearchBar
}) {
Icon(
imageVector = if (showSearchBar) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = null,
tint = Color.White
)
}
if (context.activity != null) {
Box {
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showExportDropdownMenu = !showExportDropdownMenu
}) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = Color.White
)
}
DropdownMenu(
expanded = showExportDropdownMenu,
onDismissRequest = { showExportDropdownMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
actions.forEach { (name, icon, action) ->
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = icon,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
},
text = { Text(text = name ?: "", color = Color.White) },
onClick = {
action()()
showExportDropdownMenu = false
},
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
}
}
}
}
}
}
}

View File

@@ -7,11 +7,7 @@ import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
@@ -24,14 +20,12 @@ import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
private const val BLUR_ZONE_PX = 120f
private const val BLUR_RADIUS = 30f
private const val FADE_ZONE_PX = 80f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
@@ -87,17 +81,17 @@ fun CircularRevealOverlay(
label = "wave_time_value"
)
// --- AGSL SHADER LOGIC (Android 13+) ---
val runtimeShader = remember(bitmap) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
} else null
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
}
val shaderPaint = remember(runtimeShader, bitmap) {
val shaderPaint = remember(runtimeShader) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
shader = runtimeShader
}
}
@@ -106,118 +100,12 @@ fun CircularRevealOverlay(
val center = request.originCenter
drawIntoCanvas { canvas ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
} else {
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
}
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun drawWithBlurReveal(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
}
val renderNode = android.graphics.RenderNode("blurRing").apply {
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
}
val nodeCanvas = renderNode.beginRecording()
nodeCanvas.save()
nodeCanvas.clipPath(ringPath)
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
nodeCanvas.restore()
renderNode.endRecording()
canvas.drawRenderNode(renderNode)
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
canvas.restore()
}
private fun drawWithClipFade(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
}
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
canvas.restore()
}

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.5").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("285").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("292").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,21 @@
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI
## v1.4.9
- Fix: Force AMOLED Theme for newer versions of Snapchat
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)
## v1.4.8
- Fix: Crash Issues for some devices
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
- Fix: Both side call recording for newer versions of snapchat
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
## v1.4.6
- Fix: Half Swipe Notifications for newer versions of snapchat
- Fix: No Config import/export button if Aphelion theme is turned off
## v1.4.5
- New: Spoof Snap Score Locally(tq to RSR)
- New: Video Recording Timer(tq to RSR)

View File

@@ -2190,6 +2190,10 @@
"force_message_encryption": {
"name": "Force Message Encryption",
"description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected"
},
"hide_conversation_toolbox_ui": {
"name": "Hide Conversation Toolbox UI",
"description": "Hides the PurrfectSnap conversation toolbox button that appears in Snapchat when End-To-End Encryption is enabled"
}
}
},

View File

@@ -51,6 +51,7 @@ class Experimental : ConfigContainer() {
class E2EEConfig : ConfigContainer(hasGlobalState = true) {
val encryptedMessageIndicator = boolean("encrypted_message_indicator")
val forceMessageEncryption = boolean("force_message_encryption")
val hideConversationToolboxUi = boolean("hide_conversation_toolbox_ui")
}
class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) {

View File

@@ -6,8 +6,13 @@ import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.core.ui.InAppOverlay
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
@@ -26,12 +31,21 @@ class CallRecorder : Feature("Call Recorder") {
private var wasInCall = false
private var callDownloadSession: CallDownloadSession? = null
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
private val activeRemoteStreams = ConcurrentHashMap.newKeySet<Int>()
private var fallbackMicRecord: AudioRecord? = null
private var fallbackMicJob: Job? = null
private var fallbackMicStartupJob: Job? = null
private var pendingCallEndJob: Job? = null
private var lastRemoteActivityTimestamp = 0L
private var selfSideStreamOpened = false
private val uiState get() = context.inAppOverlay.callRecorderState
private val callRecorderConfig get() = context.config.downloader.callRecorder
inner class CallStreamWrapper(
private val audioFormat: AudioFormat,
private val sourceLabel: String = "unknown",
private val onStreamOpened: (() -> Unit)? = null,
private val startTimestamp: Long = System.currentTimeMillis(),
) {
private var stream: OutputStream? = null
@@ -49,6 +63,11 @@ class CallRecorder : Feature("Call Recorder") {
audioFormat.encoding
) ?: return
)
context.log.verbose(
"Opened call stream source=$sourceLabel sampleRate=${audioFormat.sampleRate} channels=${audioFormat.channelCount} encoding=${audioFormat.encoding}",
"CallRecorder"
)
onStreamOpened?.invoke()
}
}
runCatching { stream?.write(buffer) }
@@ -63,6 +82,9 @@ class CallRecorder : Feature("Call Recorder") {
private fun finalizeSession() {
val session = callDownloadSession ?: return
context.log.verbose("Finalizing call recording session")
pendingCallEndJob?.cancel()
pendingCallEndJob = null
stopFallbackMicCapture("finalizeSession")
runCatching { session.end() }
callDownloadSession = null
streams.values.forEach { it.close() }
@@ -80,12 +102,14 @@ class CallRecorder : Feature("Call Recorder") {
}
ensureSessionStarted()
scheduleFallbackMicCapture()
}
}
private fun stopRecording() {
if (uiState.isRecording) {
uiState.isRecording = false
stopFallbackMicCapture("stopRecording")
finalizeSession()
}
}
@@ -93,6 +117,11 @@ class CallRecorder : Feature("Call Recorder") {
private fun onCallStarted(conversationId: String) {
if (wasInCall) return
wasInCall = true
activeRemoteStreams.clear()
lastRemoteActivityTimestamp = 0L
pendingCallEndJob?.cancel()
pendingCallEndJob = null
selfSideStreamOpened = false
val author = (if (context.database.getConversationType(conversationId) == 1) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
@@ -120,6 +149,11 @@ class CallRecorder : Feature("Call Recorder") {
private fun onCallEnded() {
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
wasInCall = false
activeRemoteStreams.clear()
lastRemoteActivityTimestamp = 0L
pendingCallEndJob?.cancel()
pendingCallEndJob = null
stopFallbackMicCapture("onCallEnded")
finalizeSession()
streams.clear()
@@ -178,6 +212,36 @@ class CallRecorder : Feature("Call Recorder") {
}
}
private fun markRemoteStreamActive(streamId: Int, reason: String) {
lastRemoteActivityTimestamp = System.currentTimeMillis()
pendingCallEndJob?.cancel()
pendingCallEndJob = null
if (activeRemoteStreams.add(streamId)) {
context.log.verbose("Remote stream active id=$streamId reason=$reason", "CallRecorder")
}
}
private fun markRemoteStreamInactive(streamId: Int, reason: String) {
if (activeRemoteStreams.remove(streamId)) {
context.log.verbose("Remote stream inactive id=$streamId reason=$reason", "CallRecorder")
}
scheduleCallEndCheck(reason)
}
private fun scheduleCallEndCheck(reason: String, delayMs: Long = 1500L) {
if (!wasInCall || lastRemoteActivityTimestamp == 0L || activeRemoteStreams.isNotEmpty()) return
pendingCallEndJob?.cancel()
pendingCallEndJob = context.coroutineScope.launch {
delay(delayMs)
if (!wasInCall) return@launch
if (activeRemoteStreams.isNotEmpty()) return@launch
val idleFor = System.currentTimeMillis() - lastRemoteActivityTimestamp
if (idleFor < delayMs) return@launch
context.log.verbose("Call end detected via remote inactivity reason=$reason idleFor=${idleFor}ms", "CallRecorder")
onCallEnded()
}
}
private fun ensureSessionStarted() {
if (callDownloadSession != null) return
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
@@ -186,6 +250,209 @@ class CallRecorder : Feature("Call Recorder") {
onCallStarted(conversationId)
}
private fun isCallContextActive(): Boolean {
return wasInCall || uiState.showOverlay || uiState.isRecording
}
private fun isDirectVoiceCaptureSource(audioSource: Int?): Boolean {
return audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION ||
audioSource == MediaRecorder.AudioSource.VOICE_CALL ||
audioSource == MediaRecorder.AudioSource.VOICE_UPLINK
}
private fun isLikelyCallMicSource(audioSource: Int?): Boolean {
return audioSource == MediaRecorder.AudioSource.DEFAULT ||
audioSource == MediaRecorder.AudioSource.MIC ||
audioSource == MediaRecorder.AudioSource.VOICE_RECOGNITION ||
audioSource == MediaRecorder.AudioSource.UNPROCESSED ||
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE
}
private fun registerAudioRecordStream(audioRecord: AudioRecord, reason: String): CallStreamWrapper? {
val streamId = audioRecord.hashCode()
streams[streamId]?.let { return it }
val audioSource = runCatching { audioRecord.audioSource }.getOrNull()
val shouldCapture = isDirectVoiceCaptureSource(audioSource) ||
(isCallContextActive() && isLikelyCallMicSource(audioSource))
if (!shouldCapture) return null
val format = runCatching { audioRecord.format }.getOrNull() ?: return null
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
return CallStreamWrapper(
audioFormat = format,
sourceLabel = "self-internal:$reason",
onStreamOpened = {
selfSideStreamOpened = true
if (audioRecord !== fallbackMicRecord) {
stopFallbackMicCapture("internalSelfStreamOpened")
}
}
).also {
streams[streamId] = it
context.log.verbose(
"Registered AudioRecord stream source=$audioSource reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
"CallRecorder"
)
if (isDirectVoiceCaptureSource(audioSource) || isCallContextActive()) {
ensureSessionStarted()
}
}
}
private fun shouldCaptureSelfSide(): Boolean {
return callRecorderConfig.callRecorder.get() != "only_record_others"
}
private fun scheduleFallbackMicCapture() {
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null) return
fallbackMicStartupJob?.cancel()
fallbackMicStartupJob = context.coroutineScope.launch {
delay(1200)
if (!isActive || !uiState.isRecording || selfSideStreamOpened || fallbackMicJob != null) return@launch
startFallbackMicCapture()
}
}
private fun startFallbackMicCapture() {
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null || !uiState.isRecording) return
val sampleRate = 48_000
val channelMask = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBufferSize <= 0) {
context.log.warn("Fallback mic capture unavailable: invalid min buffer size $minBufferSize", "CallRecorder")
return
}
val audioFormat = AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.setEncoding(encoding)
.build()
val audioRecord = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(audioFormat)
.setBufferSizeInBytes(minBufferSize * 2)
.build()
}.getOrElse {
context.log.error("Failed to create fallback mic recorder", it)
return
}
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
context.log.warn("Fallback mic recorder failed to initialize", "CallRecorder")
runCatching { audioRecord.release() }
return
}
fallbackMicRecord = audioRecord
context.log.verbose("Starting fallback mic capture", "CallRecorder")
fallbackMicJob = context.coroutineScope.launch(Dispatchers.IO) {
val buffer = ByteArray(minBufferSize.coerceAtLeast(2048))
val fallbackWrapper = CallStreamWrapper(
audioFormat = audioFormat,
sourceLabel = "self-fallback",
onStreamOpened = {
selfSideStreamOpened = true
}
)
val echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)?.apply {
enabled = true
}
val noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)?.apply {
enabled = true
}
try {
audioRecord.startRecording()
while (isActive && uiState.isRecording && isCallContextActive() && fallbackMicRecord === audioRecord) {
val bytesRead = runCatching {
audioRecord.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
}.getOrElse {
context.log.error("Fallback mic read failed", it)
break
}
if (bytesRead > 0) {
fallbackWrapper.write(buffer.copyOf(bytesRead))
} else {
delay(10)
}
}
} catch (e: Exception) {
context.log.error("Fallback mic capture crashed", e)
} finally {
fallbackWrapper.close()
runCatching { audioRecord.stop() }
echoCanceler?.release()
noiseSuppressor?.release()
runCatching { audioRecord.release() }
if (fallbackMicRecord === audioRecord) {
fallbackMicRecord = null
fallbackMicJob = null
}
context.log.verbose("Stopped fallback mic capture", "CallRecorder")
}
}
}
private fun stopFallbackMicCapture(reason: String) {
fallbackMicStartupJob?.cancel()
fallbackMicStartupJob = null
if (fallbackMicJob != null || fallbackMicRecord != null) {
context.log.verbose("Stopping fallback mic capture reason=$reason", "CallRecorder")
}
fallbackMicJob?.cancel()
fallbackMicJob = null
fallbackMicRecord?.let { record ->
runCatching { record.stop() }
runCatching { record.release() }
}
fallbackMicRecord = null
}
private fun isVoiceCommunicationTrack(attributes: AudioAttributes?, streamType: Int?): Boolean {
return attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
streamType == AudioManager.STREAM_VOICE_CALL ||
streamType == 6
}
private fun registerAudioTrackStream(audioTrack: AudioTrack, reason: String): CallStreamWrapper? {
val streamId = audioTrack.hashCode()
streams[streamId]?.let { return it }
val attributes = runCatching { audioTrack.audioAttributes }.getOrNull()
val streamType = runCatching { audioTrack.streamType }.getOrNull()
val isVoiceCommunication = isVoiceCommunicationTrack(attributes, streamType)
val shouldCapture = isVoiceCommunication ||
(isCallContextActive() && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (!shouldCapture) return null
val format = runCatching { audioTrack.format }.getOrNull() ?: return null
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
return CallStreamWrapper(
audioFormat = format,
sourceLabel = "remote:$reason"
).also {
streams[streamId] = it
markRemoteStreamActive(streamId, "register:$reason")
context.log.verbose(
"Registered AudioTrack stream streamType=$streamType usage=${attributes?.usage} reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
"CallRecorder"
)
if (isVoiceCommunication || isCallContextActive()) {
ensureSessionStarted()
}
}
}
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
if (requestedLength <= 0 || maxLength <= 0) return null
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
@@ -209,6 +476,13 @@ class CallRecorder : Feature("Call Recorder") {
}
}
private fun copyFloatArrayToByteArray(data: FloatArray, offset: Int, sampleCount: Int): ByteArray? {
val (safeOffset, safeLength) = clampCopyRange(offset, sampleCount, data.size) ?: return null
return ByteArray(safeLength * Float.SIZE_BYTES).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(data, safeOffset, safeLength)
}
}
override fun init() {
if (callRecorderConfig.callRecorder.getNullable() == null) return
@@ -224,30 +498,16 @@ class CallRecorder : Feature("Call Recorder") {
AudioRecord::class.java.apply {
if (recorderConfig == "only_record_others") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val audioSource = runCatching { param.arg<Int>(0) }.getOrNull()
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
val shouldCapture = isVoiceCommunication ||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (shouldCapture) {
val format = AudioFormat.Builder()
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
.build()
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
if (isVoiceCommunication) {
ensureSessionStarted()
}
}
registerAudioRecordStream(param.thisObject<AudioRecord>(), "constructor")
}
hook("read", HookStage.AFTER) { param ->
val result = param.getResult() as? Int ?: 0
if (result <= 0) return@hook
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val audioRecord = param.thisObject<AudioRecord>()
val wrapper = streams[param.thisObject<Any>().hashCode()]
?: registerAudioRecordStream(audioRecord, "read")
?: return@hook
val buffer = when (val data = param.arg<Any>(0)) {
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
@@ -263,11 +523,19 @@ class CallRecorder : Feature("Call Recorder") {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
is FloatArray -> {
val offset = param.argNullable<Int>(1) ?: 0
copyFloatArrayToByteArray(data, offset, result) ?: return@hook
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("startRecording", HookStage.AFTER) {
registerAudioRecordStream(it.thisObject<AudioRecord>(), "startRecording")
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("release", HookStage.BEFORE) {
streams.remove(it.thisObject<Any>().hashCode())?.close()
@@ -278,29 +546,15 @@ class CallRecorder : Feature("Call Recorder") {
AudioTrack::class.java.apply {
if (recorderConfig == "only_record_self") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val streamType = runCatching { param.arg<Int>(0) }.getOrNull()
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
streamType == AudioManager.STREAM_VOICE_CALL ||
streamType == 6
val shouldCapture = isVoiceCommunication ||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (shouldCapture) {
val format = AudioFormat.Builder()
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
.build()
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
if (isVoiceCommunication) {
ensureSessionStarted()
}
}
registerAudioTrackStream(param.thisObject<AudioTrack>(), "constructor")
}
hook("write", HookStage.BEFORE) { param ->
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val streamId = param.thisObject<Any>().hashCode()
markRemoteStreamActive(streamId, "write")
val wrapper = streams[streamId]
?: registerAudioTrackStream(param.thisObject<AudioTrack>(), "write")
?: return@hook
val data = param.arg<Any>(0)
val buffer = when (data) {
@@ -328,13 +582,34 @@ class CallRecorder : Feature("Call Recorder") {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
is FloatArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
copyFloatArrayToByteArray(data, offset, requestedSize) ?: return@hook
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("play", HookStage.AFTER) {
val audioTrack = it.thisObject<AudioTrack>()
markRemoteStreamActive(audioTrack.hashCode(), "play")
registerAudioTrackStream(audioTrack, "play")
}
hook("stop", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "stop")
checkStreamsAndCleanup()
}
hook("pause", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "pause")
}
hook("flush", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "flush")
}
hook("release", HookStage.BEFORE) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "release")
streams.remove(it.thisObject<Any>().hashCode())?.close()
checkStreamsAndCleanup()
}

View File

@@ -8,13 +8,20 @@ import android.graphics.drawable.shapes.Shape
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageState
@@ -29,9 +36,10 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.*
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.features.impl.ui.ConversationToolbox
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
import me.eternal.purrfectsnap.core.ui.findParent
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -185,6 +193,16 @@ class EndToEndEncryption : MessagingRuleFeature(
}
}
private fun resolveKeyActionContainer(startView: View): ViewGroup? {
val ancestors = generateSequence(startView) { current ->
current.parent as? View
}.filterIsInstance<ViewGroup>().toList()
return ancestors.firstOrNull { candidate ->
candidate is LinearLayout && candidate.orientation == LinearLayout.VERTICAL
} ?: ancestors.firstOrNull()
}
@SuppressLint("SetTextI18n", "DiscouragedApi")
override fun init() {
if (!isEnabled) return
@@ -231,30 +249,34 @@ class EndToEndEncryption : MessagingRuleFeature(
}
onNextActivityCreate(defer = true) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
val hideConversationToolboxUi by context.config.experimental.e2eEncryption.hideConversationToolboxUi
if (!hideConversationToolboxUi) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}
@@ -264,9 +286,7 @@ class EndToEndEncryption : MessagingRuleFeature(
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
val viewGroup = event.view.findParent(maxIteration = 3) {
it is LinearLayout
} as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage
val viewGroup = resolveKeyActionContainer(event.view) ?: return@chatMessage
viewGroup.findViewWithTag<View>(specialCard)?.also {
viewGroup.removeView(it)
@@ -289,27 +309,45 @@ class EndToEndEncryption : MessagingRuleFeature(
val publicKey = pkRequests[messageId.toLong()]
if (publicKey != null || secret != null) {
viewGroup.addView(createComposeView(context.mainActivity!!) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
onClick = {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
) {
createComposeView(viewGroup.context) {
PurrfectOverlayTheme {
val actionShape = RoundedCornerShape(22.dp)
val borderBrush = Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.70f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
)
)
Box(
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, bottom = 6.dp),
contentAlignment = Alignment.Center
) {
if (publicKey != null) {
Text(translation["accept_public_key_button"])
}
if (secret != null) {
Text(translation["accept_secret_button"])
Box(
modifier = Modifier
.clip(actionShape)
.background(PurrfectOverlayPalette.cardOverlay, actionShape)
.border(1.15.dp, borderBrush, actionShape)
.padding(horizontal = 18.dp, vertical = 11.dp)
) {
if (publicKey != null) {
Text(
text = translation["accept_public_key_button"],
color = PurrfectOverlayPalette.textPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
}
if (secret != null) {
Text(
text = translation["accept_secret_button"],
color = PurrfectOverlayPalette.textPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@@ -319,7 +357,16 @@ class EndToEndEncryption : MessagingRuleFeature(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
})
setOnClickListener {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
viewGroup.addView(this)
}
}
}
}

View File

@@ -1,18 +1,31 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.widget.ProgressBar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageUpdate
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.util.CallbackBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -61,20 +74,41 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}
var job: Job? = null
val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle("Processing...")
.setView(ProgressBar(context.mainActivity).apply {
setPadding(10, 10, 10, 10)
})
.setOnDismissListener { job?.cancel() }
.show()
val processedCount = mutableIntStateOf(0)
val dialog = createComposeAlertDialog(context.mainActivity!!, builder = {
setOnDismissListener { job?.cancel() }
}) {
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
title = "Marking Snaps as Seen",
subtitle = "Updating read state for queued snaps",
icon = Icons.Default.Visibility
) {
Column(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = PurrfectOverlayPalette.glowSecondary,
trackColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.18f)
)
Text(
text = "${processedCount.intValue}/${messageIds.size}",
color = PurrfectOverlayPalette.textSecondary
)
}
}
}
}.apply { show() }
context.coroutineScope.launch(Dispatchers.IO) {
messageIds.forEach { messageId ->
messageIds.forEachIndexed { index, messageId ->
markSnapAsSeen(conversationId, messageId)
delay(Random.nextLong(20, 60))
context.runOnUiThread {
dialog.setTitle("Processing... (${messageIds.indexOf(messageId) + 1}/${messageIds.size})")
processedCount.intValue = index + 1
}
}
}.also { job = it }.invokeOnCompletion {
@@ -153,4 +187,4 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}
}
}
}
}

View File

@@ -3,8 +3,28 @@ package me.eternal.purrfectsnap.core.features.impl.messaging
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
@@ -16,12 +36,46 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) {
if (motionEvent.action != MotionEvent.ACTION_UP) return
param.setResult(true)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["call_start_confirmation.dialog_title"])
.setMessage(context.translation["call_start_confirmation.dialog_message"])
.setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() }
.setNeutralButton(context.translation["button.negative"]) { _, _ -> }
.show()
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = Modifier.fillMaxWidth(),
title = context.translation["call_start_confirmation.dialog_title"],
subtitle = context.translation["call_start_confirmation.dialog_message"],
icon = Icons.Default.Call
) {
val actionShape = RoundedCornerShape(16.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.CenterHorizontally)
) {
Button(
modifier = Modifier.width(120.dp),
onClick = { alertDialog.dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(context.translation["button.negative"])
}
Button(
modifier = Modifier.width(120.dp),
onClick = {
alertDialog.dismiss()
onConfirm()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.26f),
contentColor = Color.White
)
) {
Text(context.translation["button.positive"])
}
}
}
}
}.show()
}
override fun init() {

View File

@@ -37,6 +37,11 @@ class FriendTracker : Feature("Friend Tracker") {
))
} }
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
peekingStateListeners.add(listener)
}
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
return runCatching {
@@ -207,6 +212,12 @@ class FriendTracker : Feature("Friend Tracker") {
else -> {}
}
when (eventType) {
TrackerEventType.STARTED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, true) }
TrackerEventType.STOPPED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, false) }
else -> {}
}
dispatchEvents(eventType, conversationId, userId)
}
@@ -261,7 +272,8 @@ class FriendTracker : Feature("Friend Tracker") {
typing = stateMap[4],
wasTyping = stateMap[5],
speaking = stateMap[6] && stateMap[4],
peeking = stateMap[8]
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
)
}
@@ -385,7 +397,8 @@ class FriendTracker : Feature("Friend Tracker") {
override fun init() {
val sessionEventsConfig = context.config.friendTracker
if (sessionEventsConfig.globalState != true) return
val shouldProcessSessionEvents = sessionEventsConfig.globalState == true || peekingStateListeners.isNotEmpty()
if (!shouldProcessSessionEvents) return
if (sessionEventsConfig.allowRunningInBackground.get()) {
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
@@ -402,7 +415,7 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
if (sessionEventsConfig.recordMessagingEvents.get()) {
if (sessionEventsConfig.recordMessagingEvents.get() || peekingStateListeners.isNotEmpty()) {
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
hook("onReceive", HookStage.BEFORE) { param ->
param.setResult(null)

View File

@@ -6,17 +6,10 @@ import android.app.NotificationManager
import android.app.PendingIntent
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
private val peekingConversations = ConcurrentHashMap<String, List<String>>()
private val startPeekingTimestamps = ConcurrentHashMap<String, Long>()
private val startPeekingTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
private val halfSwipeListeners = mutableListOf<(String, String, Long) -> Unit>()
private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java)
@@ -39,44 +32,11 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
override fun init() {
if (context.config.messaging.halfSwipeNotifier.globalState != true) return
lateinit var presenceService: Any
findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) {
presenceService = it.thisObject()
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("PresenceServiceDelegate")?.hook("notifyActiveConversationsChanged", HookStage.BEFORE) {
val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants)
if (activeConversations.isEmpty()) {
peekingConversations.forEach {
val conversationId = it.key
val peekingParticipantsIds = it.value
peekingParticipantsIds.forEach { userId ->
endPeeking(conversationId, userId)
}
}
peekingConversations.clear()
return@hook
}
activeConversations.forEach { (conversationId, conversationInfo) ->
val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach
val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList()
val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet()
val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet()
newPeekingParticipantsIds.forEach { userId ->
startPeeking(conversationId.toString(), userId)
}
exitedPeekingParticipantsIds.forEach { userId ->
endPeeking(conversationId.toString(), userId)
}
peekingConversations[conversationId.toString()] = peekingParticipantsIds
}
context.feature(FriendTracker::class).addOnPeekingStateChangedListener { conversationId, userId, peeking ->
if (peeking) {
startPeeking(conversationId, userId)
} else {
endPeeking(conversationId, userId)
}
}
}
@@ -139,4 +99,4 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
}
}
}
}
}

View File

@@ -1,10 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.content.res.TypedArray
import android.graphics.drawable.ColorDrawable
import android.util.TypedValue
import android.view.View
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
class CustomTheming : Feature("Custom Theming") {
@@ -50,37 +53,80 @@ class CustomTheming : Feature("Custom Theming") {
).any { it in name }
}
private fun patchTypedArray(result: TypedArray, attrIds: IntArray?) {
val requestedAttrs = attrIds?.takeIf { it.isNotEmpty() } ?: return
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return
val stride = (typedArrayData.size / requestedAttrs.size).takeIf { it >= 2 } ?: return
requestedAttrs.forEachIndexed { index, attrId ->
val offset = index * stride
if (offset + 1 >= typedArrayData.size) return@forEachIndexed
val type = typedArrayData[offset]
if (type !in colorTypes) return@forEachIndexed
val originalColor = runCatching { result.getColor(index, Int.MIN_VALUE) }.getOrNull()
?.takeIf { it != Int.MIN_VALUE }
?: return@forEachIndexed
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
if (!shouldPatch) return@forEachIndexed
typedArrayData[offset + 1] = amoledBlack
if (patchedAttrIds.add(attrId)) {
context.log.verbose(
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
)
}
}
}
private fun patchProgrammaticColor(color: Int): Int {
return if (isNearBlackOpaque(color) && color != amoledBlack) amoledBlack else color
}
override fun init() {
if (!context.config.userInterface.forceAmoledTheme.get()) return
onNextActivityCreate {
context.androidContext.theme.javaClass
.getMethod("obtainStyledAttributes", IntArray::class.java)
.hook(HookStage.AFTER) { param ->
val array = param.arg<IntArray>(0)
val attrId = array[0]
val result = param.getResult() as TypedArray
val type = result.getType(0)
if (type !in colorTypes) return@hook
val originalColor = runCatching { result.getColor(0, Int.MIN_VALUE) }.getOrNull()
?.takeIf { it != Int.MIN_VALUE }
?: return@hook
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
if (!shouldPatch) return@hook
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return@hook
if (typedArrayData.size < 2) return@hook
typedArrayData[1] = amoledBlack
if (patchedAttrIds.add(attrId)) {
context.log.verbose(
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
)
}
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
val result = param.getResult() as? TypedArray ?: return@hook
patchTypedArray(result, requestedAttrs)
}
context.androidContext.javaClass
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
val result = param.getResult() as? TypedArray ?: return@hook
patchTypedArray(result, requestedAttrs)
}
View::class.java.hook("setBackgroundColor", HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hook
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
ColorDrawable::class.java.hookConstructor(HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hookConstructor
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
ColorDrawable::class.java.hook("setColor", HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hook
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
}
}
}

View File

@@ -47,16 +47,31 @@ fun View.addForegroundDrawable(tag: String, drawable: Drawable) {
updateForegroundDrawable()
}
fun View.triggerCloseTouchEvent() {
arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach {
this.dispatchTouchEvent(
MotionEvent.obtain(
SystemClock.uptimeMillis(),
SystemClock.uptimeMillis(),
it, 0f, 0f, 0
)
)
}
fun View.dispatchSyntheticTap(x: Float, y: Float, tapDurationMs: Long = 50L) {
val downTime = SystemClock.uptimeMillis()
val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
dispatchTouchEvent(downEvent)
downEvent.recycle()
val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0)
dispatchTouchEvent(upEvent)
upEvent.recycle()
}
fun View.triggerCloseTouchEvent(x: Float = 0f, y: Float = 0f, tapDurationMs: Long = 50L) {
dispatchSyntheticTap(x, y, tapDurationMs)
}
fun View.triggerCloseTouchEventAtFraction(
xFraction: Float,
yFraction: Float = 0.5f,
tapDurationMs: Long = 50L
) {
val targetWidth = width.takeIf { it > 0 } ?: measuredWidth
val targetHeight = height.takeIf { it > 0 } ?: measuredHeight
val x = if (targetWidth > 0) targetWidth * xFraction.coerceIn(0f, 1f) else 0f
val y = if (targetHeight > 0) targetHeight * yFraction.coerceIn(0f, 1f) else 0f
triggerCloseTouchEvent(x, y, tapDurationMs)
}
fun Activity.triggerRootCloseTouchEvent() {

View File

@@ -45,6 +45,7 @@ import me.eternal.purrfectsnap.core.ui.iterateParent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.randomTag
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEventAtFraction
import me.eternal.purrfectsnap.core.util.SNAPCHAT_13_80_VERSION
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -259,23 +260,113 @@ class OperaViewerIcons : AbstractMenu() {
visibleRect.width() > 0
}
private fun hasVisibleOpenLayout(view: View): Boolean {
private fun findVisibleOpenLayout(view: View): View? {
if (view.javaClass.hasNameSuffixInHierarchy("OpenLayout") && isActuallyVisible(view)) {
return true
return view
}
val viewGroup = view as? ViewGroup ?: return false
val viewGroup = view as? ViewGroup ?: return null
for (index in 0 until viewGroup.childCount) {
if (hasVisibleOpenLayout(viewGroup.getChildAt(index))) {
return true
}
findVisibleOpenLayout(viewGroup.getChildAt(index))?.let { return it }
}
return false
return null
}
private fun findVisibleModernViewerContainer(): View? {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return null
return findVisibleOpenLayout(contentView)
}
private fun hasVisibleModernViewerContainer(): Boolean {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return false
return hasVisibleOpenLayout(contentView)
return findVisibleModernViewerContainer() != null
}
private fun currentViewerMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
return mediaDownloader.resolveViewerMessageContextFromParamMap()?.also {
viewerMessageContextState.value = it
} ?: viewerMessageContextState.value
}
private fun hasViewerAdvanced(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
): Boolean {
if (!hasVisibleModernViewerContainer()) {
return true
}
return currentViewerMessageContext(mediaDownloader)?.let { it != originalMessageContext } == true
}
private suspend fun waitForViewerAdvance(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext,
timeoutMs: Long
): Boolean {
var elapsedMs = 0L
while (elapsedMs < timeoutMs) {
delay(40)
elapsedMs += 40
if (hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
return true
}
}
return hasViewerAdvanced(mediaDownloader, originalMessageContext)
}
private fun dispatchLegacySkipGesture(parent: ViewGroup?) {
if (parent != null) {
var touchedParent = false
parent.iterateParent {
touchedParent = true
it.triggerCloseTouchEvent()
false
}
if (touchedParent) return
}
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
private fun dispatchForwardHotZoneTap(target: View?, xFraction: Float) {
target?.triggerCloseTouchEventAtFraction(xFraction = xFraction, yFraction = 0.5f)
}
private suspend fun skipMarkedSnap(
parent: ViewGroup?,
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
) {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content)
val skipAttempts = listOf<suspend () -> Unit>(
{
dispatchLegacySkipGesture(parent)
},
{
dispatchForwardHotZoneTap(findVisibleModernViewerContainer() ?: contentView, 0.88f)
},
{
dispatchForwardHotZoneTap(contentView ?: findVisibleModernViewerContainer(), 0.88f)
},
{
val target = findVisibleModernViewerContainer() ?: contentView
dispatchForwardHotZoneTap(target, 0.88f)
delay(55)
if (!hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
dispatchForwardHotZoneTap(target, 0.94f)
}
}
)
for ((index, attempt) in skipAttempts.withIndex()) {
attempt()
if (waitForViewerAdvance(mediaDownloader, originalMessageContext, if (index == 0) 120L else 180L)) {
return
}
}
}
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
@@ -326,7 +417,8 @@ class OperaViewerIcons : AbstractMenu() {
}
private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) {
val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return
val mediaDownloader = context.feature(MediaDownloader::class)
val messageContext = resolveCurrentMessageContext(mediaDownloader) ?: return
val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen(
messageContext.conversationId,
messageContext.clientMessageId
@@ -335,16 +427,7 @@ class OperaViewerIcons : AbstractMenu() {
if (result == "DUPLICATEREQUEST" || result == null) {
if (context.config.messaging.skipWhenMarkingAsSeen.get()) {
withContext(Dispatchers.Main) {
if (parent != null) {
parent.iterateParent {
it.triggerCloseTouchEvent()
false
}
} else {
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
skipMarkedSnap(parent, mediaDownloader, messageContext)
}
}
}

View File

@@ -117,18 +117,29 @@ class ConversationManager(
set("mServerConversationId", conversationId.toSnapUUID().instanceNonNull())
set("mServerMessageId", serverMessageId)
}
val conversationUuid = conversationId.toSnapUUID().instanceNonNull()
fetchMessageByServerId.invoke(
instanceNonNull(),
serverMessageIdentifier,
CallbackBuilder(getCallbackClass("FetchMessageCallback"))
.override("onFetchMessageComplete") { param ->
onSuccess(Message(param.arg(0)))
}
.override("onError") {
onError(it.arg<Any>(0).toString())
}.build()
)
val callback = CallbackBuilder(getCallbackClass("FetchMessageCallback"))
.override("onFetchMessageComplete") { param ->
onSuccess(Message(param.arg(0)))
}
.override("onError") {
onError(it.arg<Any>(0).toString())
}.build()
val args = fetchMessageByServerId.parameterTypes.mapIndexed { index, parameterType ->
when {
parameterType.isInstance(serverMessageIdentifier) -> serverMessageIdentifier
parameterType.isInstance(callback) -> callback
parameterType.isInstance(conversationUuid) -> conversationUuid
parameterType == Boolean::class.javaPrimitiveType || parameterType == Boolean::class.javaObjectType -> false
else -> throw IllegalStateException(
"Unsupported fetchMessageByServerId parameter at index $index: ${parameterType.name}"
)
}
}.toTypedArray()
fetchMessageByServerId.invoke(instanceNonNull(), *args)
}
fun fetchMessagesByServerIds(conversationId: String, serverMessageIds: List<Long>, onSuccess: (List<Message>) -> Unit, onError: (error: String) -> Unit) {

View File

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