Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 | ||
|
|
58c4be44f2 |
Binary file not shown.
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.4.8").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("288").get().toInt())
|
||||
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
|
||||
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
|
||||
// Include version code so each release has a different hash; use random for uniqueness within same version.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## 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)
|
||||
|
||||
@@ -6,8 +6,13 @@ import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.core.ui.InAppOverlay
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
@@ -26,12 +31,21 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private var wasInCall = false
|
||||
private var callDownloadSession: CallDownloadSession? = null
|
||||
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
|
||||
private val activeRemoteStreams = ConcurrentHashMap.newKeySet<Int>()
|
||||
private var fallbackMicRecord: AudioRecord? = null
|
||||
private var fallbackMicJob: Job? = null
|
||||
private var fallbackMicStartupJob: Job? = null
|
||||
private var pendingCallEndJob: Job? = null
|
||||
private var lastRemoteActivityTimestamp = 0L
|
||||
private var selfSideStreamOpened = false
|
||||
|
||||
private val uiState get() = context.inAppOverlay.callRecorderState
|
||||
private val callRecorderConfig get() = context.config.downloader.callRecorder
|
||||
|
||||
inner class CallStreamWrapper(
|
||||
private val audioFormat: AudioFormat,
|
||||
private val sourceLabel: String = "unknown",
|
||||
private val onStreamOpened: (() -> Unit)? = null,
|
||||
private val startTimestamp: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
private var stream: OutputStream? = null
|
||||
@@ -49,6 +63,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
audioFormat.encoding
|
||||
) ?: return
|
||||
)
|
||||
context.log.verbose(
|
||||
"Opened call stream source=$sourceLabel sampleRate=${audioFormat.sampleRate} channels=${audioFormat.channelCount} encoding=${audioFormat.encoding}",
|
||||
"CallRecorder"
|
||||
)
|
||||
onStreamOpened?.invoke()
|
||||
}
|
||||
}
|
||||
runCatching { stream?.write(buffer) }
|
||||
@@ -63,6 +82,9 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun finalizeSession() {
|
||||
val session = callDownloadSession ?: return
|
||||
context.log.verbose("Finalizing call recording session")
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("finalizeSession")
|
||||
runCatching { session.end() }
|
||||
callDownloadSession = null
|
||||
streams.values.forEach { it.close() }
|
||||
@@ -80,12 +102,14 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
|
||||
ensureSessionStarted()
|
||||
scheduleFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
if (uiState.isRecording) {
|
||||
uiState.isRecording = false
|
||||
stopFallbackMicCapture("stopRecording")
|
||||
finalizeSession()
|
||||
}
|
||||
}
|
||||
@@ -93,6 +117,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallStarted(conversationId: String) {
|
||||
if (wasInCall) return
|
||||
wasInCall = true
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
selfSideStreamOpened = false
|
||||
|
||||
val author = (if (context.database.getConversationType(conversationId) == 1) {
|
||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
|
||||
@@ -120,6 +149,11 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
private fun onCallEnded() {
|
||||
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
|
||||
wasInCall = false
|
||||
activeRemoteStreams.clear()
|
||||
lastRemoteActivityTimestamp = 0L
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
stopFallbackMicCapture("onCallEnded")
|
||||
finalizeSession()
|
||||
streams.clear()
|
||||
|
||||
@@ -178,6 +212,36 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamActive(streamId: Int, reason: String) {
|
||||
lastRemoteActivityTimestamp = System.currentTimeMillis()
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = null
|
||||
if (activeRemoteStreams.add(streamId)) {
|
||||
context.log.verbose("Remote stream active id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
}
|
||||
|
||||
private fun markRemoteStreamInactive(streamId: Int, reason: String) {
|
||||
if (activeRemoteStreams.remove(streamId)) {
|
||||
context.log.verbose("Remote stream inactive id=$streamId reason=$reason", "CallRecorder")
|
||||
}
|
||||
scheduleCallEndCheck(reason)
|
||||
}
|
||||
|
||||
private fun scheduleCallEndCheck(reason: String, delayMs: Long = 1500L) {
|
||||
if (!wasInCall || lastRemoteActivityTimestamp == 0L || activeRemoteStreams.isNotEmpty()) return
|
||||
pendingCallEndJob?.cancel()
|
||||
pendingCallEndJob = context.coroutineScope.launch {
|
||||
delay(delayMs)
|
||||
if (!wasInCall) return@launch
|
||||
if (activeRemoteStreams.isNotEmpty()) return@launch
|
||||
val idleFor = System.currentTimeMillis() - lastRemoteActivityTimestamp
|
||||
if (idleFor < delayMs) return@launch
|
||||
context.log.verbose("Call end detected via remote inactivity reason=$reason idleFor=${idleFor}ms", "CallRecorder")
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSessionStarted() {
|
||||
if (callDownloadSession != null) return
|
||||
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
|
||||
@@ -186,6 +250,209 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
onCallStarted(conversationId)
|
||||
}
|
||||
|
||||
private fun isCallContextActive(): Boolean {
|
||||
return wasInCall || uiState.showOverlay || uiState.isRecording
|
||||
}
|
||||
|
||||
private fun isDirectVoiceCaptureSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_CALL ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_UPLINK
|
||||
}
|
||||
|
||||
private fun isLikelyCallMicSource(audioSource: Int?): Boolean {
|
||||
return audioSource == MediaRecorder.AudioSource.DEFAULT ||
|
||||
audioSource == MediaRecorder.AudioSource.MIC ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_RECOGNITION ||
|
||||
audioSource == MediaRecorder.AudioSource.UNPROCESSED ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE
|
||||
}
|
||||
|
||||
private fun registerAudioRecordStream(audioRecord: AudioRecord, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioRecord.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val audioSource = runCatching { audioRecord.audioSource }.getOrNull()
|
||||
val shouldCapture = isDirectVoiceCaptureSource(audioSource) ||
|
||||
(isCallContextActive() && isLikelyCallMicSource(audioSource))
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioRecord.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "self-internal:$reason",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
if (audioRecord !== fallbackMicRecord) {
|
||||
stopFallbackMicCapture("internalSelfStreamOpened")
|
||||
}
|
||||
}
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
context.log.verbose(
|
||||
"Registered AudioRecord stream source=$audioSource reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isDirectVoiceCaptureSource(audioSource) || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCaptureSelfSide(): Boolean {
|
||||
return callRecorderConfig.callRecorder.get() != "only_record_others"
|
||||
}
|
||||
|
||||
private fun scheduleFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null) return
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = context.coroutineScope.launch {
|
||||
delay(1200)
|
||||
if (!isActive || !uiState.isRecording || selfSideStreamOpened || fallbackMicJob != null) return@launch
|
||||
startFallbackMicCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFallbackMicCapture() {
|
||||
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null || !uiState.isRecording) return
|
||||
|
||||
val sampleRate = 48_000
|
||||
val channelMask = AudioFormat.CHANNEL_IN_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
|
||||
if (minBufferSize <= 0) {
|
||||
context.log.warn("Fallback mic capture unavailable: invalid min buffer size $minBufferSize", "CallRecorder")
|
||||
return
|
||||
}
|
||||
|
||||
val audioFormat = AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelMask)
|
||||
.setEncoding(encoding)
|
||||
.build()
|
||||
|
||||
val audioRecord = runCatching {
|
||||
AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(audioFormat)
|
||||
.setBufferSizeInBytes(minBufferSize * 2)
|
||||
.build()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to create fallback mic recorder", it)
|
||||
return
|
||||
}
|
||||
|
||||
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
|
||||
context.log.warn("Fallback mic recorder failed to initialize", "CallRecorder")
|
||||
runCatching { audioRecord.release() }
|
||||
return
|
||||
}
|
||||
|
||||
fallbackMicRecord = audioRecord
|
||||
context.log.verbose("Starting fallback mic capture", "CallRecorder")
|
||||
|
||||
fallbackMicJob = context.coroutineScope.launch(Dispatchers.IO) {
|
||||
val buffer = ByteArray(minBufferSize.coerceAtLeast(2048))
|
||||
val fallbackWrapper = CallStreamWrapper(
|
||||
audioFormat = audioFormat,
|
||||
sourceLabel = "self-fallback",
|
||||
onStreamOpened = {
|
||||
selfSideStreamOpened = true
|
||||
}
|
||||
)
|
||||
val echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
val noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)?.apply {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
try {
|
||||
audioRecord.startRecording()
|
||||
while (isActive && uiState.isRecording && isCallContextActive() && fallbackMicRecord === audioRecord) {
|
||||
val bytesRead = runCatching {
|
||||
audioRecord.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||
}.getOrElse {
|
||||
context.log.error("Fallback mic read failed", it)
|
||||
break
|
||||
}
|
||||
|
||||
if (bytesRead > 0) {
|
||||
fallbackWrapper.write(buffer.copyOf(bytesRead))
|
||||
} else {
|
||||
delay(10)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
context.log.error("Fallback mic capture crashed", e)
|
||||
} finally {
|
||||
fallbackWrapper.close()
|
||||
runCatching { audioRecord.stop() }
|
||||
echoCanceler?.release()
|
||||
noiseSuppressor?.release()
|
||||
runCatching { audioRecord.release() }
|
||||
if (fallbackMicRecord === audioRecord) {
|
||||
fallbackMicRecord = null
|
||||
fallbackMicJob = null
|
||||
}
|
||||
context.log.verbose("Stopped fallback mic capture", "CallRecorder")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopFallbackMicCapture(reason: String) {
|
||||
fallbackMicStartupJob?.cancel()
|
||||
fallbackMicStartupJob = null
|
||||
if (fallbackMicJob != null || fallbackMicRecord != null) {
|
||||
context.log.verbose("Stopping fallback mic capture reason=$reason", "CallRecorder")
|
||||
}
|
||||
fallbackMicJob?.cancel()
|
||||
fallbackMicJob = null
|
||||
fallbackMicRecord?.let { record ->
|
||||
runCatching { record.stop() }
|
||||
runCatching { record.release() }
|
||||
}
|
||||
fallbackMicRecord = null
|
||||
}
|
||||
|
||||
private fun isVoiceCommunicationTrack(attributes: AudioAttributes?, streamType: Int?): Boolean {
|
||||
return attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
}
|
||||
|
||||
private fun registerAudioTrackStream(audioTrack: AudioTrack, reason: String): CallStreamWrapper? {
|
||||
val streamId = audioTrack.hashCode()
|
||||
streams[streamId]?.let { return it }
|
||||
|
||||
val attributes = runCatching { audioTrack.audioAttributes }.getOrNull()
|
||||
val streamType = runCatching { audioTrack.streamType }.getOrNull()
|
||||
val isVoiceCommunication = isVoiceCommunicationTrack(attributes, streamType)
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(isCallContextActive() && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
if (!shouldCapture) return null
|
||||
|
||||
val format = runCatching { audioTrack.format }.getOrNull() ?: return null
|
||||
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
|
||||
|
||||
return CallStreamWrapper(
|
||||
audioFormat = format,
|
||||
sourceLabel = "remote:$reason"
|
||||
).also {
|
||||
streams[streamId] = it
|
||||
markRemoteStreamActive(streamId, "register:$reason")
|
||||
context.log.verbose(
|
||||
"Registered AudioTrack stream streamType=$streamType usage=${attributes?.usage} reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
|
||||
"CallRecorder"
|
||||
)
|
||||
if (isVoiceCommunication || isCallContextActive()) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
|
||||
if (requestedLength <= 0 || maxLength <= 0) return null
|
||||
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
|
||||
@@ -209,6 +476,13 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFloatArrayToByteArray(data: FloatArray, offset: Int, sampleCount: Int): ByteArray? {
|
||||
val (safeOffset, safeLength) = clampCopyRange(offset, sampleCount, data.size) ?: return null
|
||||
return ByteArray(safeLength * Float.SIZE_BYTES).also {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (callRecorderConfig.callRecorder.getNullable() == null) return
|
||||
|
||||
@@ -224,30 +498,16 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioRecord::class.java.apply {
|
||||
if (recorderConfig == "only_record_others") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val audioSource = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioRecordStream(param.thisObject<AudioRecord>(), "constructor")
|
||||
}
|
||||
|
||||
hook("read", HookStage.AFTER) { param ->
|
||||
val result = param.getResult() as? Int ?: 0
|
||||
if (result <= 0) return@hook
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val audioRecord = param.thisObject<AudioRecord>()
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()]
|
||||
?: registerAudioRecordStream(audioRecord, "read")
|
||||
?: return@hook
|
||||
|
||||
val buffer = when (val data = param.arg<Any>(0)) {
|
||||
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
|
||||
@@ -263,11 +523,19 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
copyFloatArrayToByteArray(data, offset, result) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("startRecording", HookStage.AFTER) {
|
||||
registerAudioRecordStream(it.thisObject<AudioRecord>(), "startRecording")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("release", HookStage.BEFORE) {
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
@@ -278,29 +546,15 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
AudioTrack::class.java.apply {
|
||||
if (recorderConfig == "only_record_self") return@apply
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
|
||||
val streamType = runCatching { param.arg<Int>(0) }.getOrNull()
|
||||
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
|
||||
streamType == AudioManager.STREAM_VOICE_CALL ||
|
||||
streamType == 6
|
||||
val shouldCapture = isVoiceCommunication ||
|
||||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
|
||||
|
||||
if (shouldCapture) {
|
||||
val format = AudioFormat.Builder()
|
||||
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
|
||||
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
|
||||
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
|
||||
.build()
|
||||
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
|
||||
if (isVoiceCommunication) {
|
||||
ensureSessionStarted()
|
||||
}
|
||||
}
|
||||
registerAudioTrackStream(param.thisObject<AudioTrack>(), "constructor")
|
||||
}
|
||||
|
||||
hook("write", HookStage.BEFORE) { param ->
|
||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||
val streamId = param.thisObject<Any>().hashCode()
|
||||
markRemoteStreamActive(streamId, "write")
|
||||
val wrapper = streams[streamId]
|
||||
?: registerAudioTrackStream(param.thisObject<AudioTrack>(), "write")
|
||||
?: return@hook
|
||||
val data = param.arg<Any>(0)
|
||||
|
||||
val buffer = when (data) {
|
||||
@@ -328,13 +582,34 @@ class CallRecorder : Feature("Call Recorder") {
|
||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
|
||||
}
|
||||
}
|
||||
is FloatArray -> {
|
||||
val offset = param.argNullable<Int>(1) ?: 0
|
||||
val requestedSize = param.argNullable<Int>(2) ?: data.size
|
||||
copyFloatArrayToByteArray(data, offset, requestedSize) ?: return@hook
|
||||
}
|
||||
else -> return@hook
|
||||
}
|
||||
wrapper.write(buffer)
|
||||
}
|
||||
|
||||
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
|
||||
hook("play", HookStage.AFTER) {
|
||||
val audioTrack = it.thisObject<AudioTrack>()
|
||||
markRemoteStreamActive(audioTrack.hashCode(), "play")
|
||||
registerAudioTrackStream(audioTrack, "play")
|
||||
}
|
||||
|
||||
hook("stop", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "stop")
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
hook("pause", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "pause")
|
||||
}
|
||||
hook("flush", HookStage.AFTER) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "flush")
|
||||
}
|
||||
hook("release", HookStage.BEFORE) {
|
||||
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "release")
|
||||
streams.remove(it.thisObject<Any>().hashCode())?.close()
|
||||
checkStreamsAndCleanup()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,20 @@ import android.graphics.drawable.shapes.Shape
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
@@ -29,9 +36,10 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.*
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.features.impl.ui.ConversationToolbox
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
|
||||
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.ui.findParent
|
||||
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
|
||||
import me.eternal.purrfectsnap.core.util.EvictingMap
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
@@ -185,6 +193,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveKeyActionContainer(startView: View): ViewGroup? {
|
||||
val ancestors = generateSequence(startView) { current ->
|
||||
current.parent as? View
|
||||
}.filterIsInstance<ViewGroup>().toList()
|
||||
|
||||
return ancestors.firstOrNull { candidate ->
|
||||
candidate is LinearLayout && candidate.orientation == LinearLayout.VERTICAL
|
||||
} ?: ancestors.firstOrNull()
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n", "DiscouragedApi")
|
||||
override fun init() {
|
||||
if (!isEnabled) return
|
||||
@@ -264,9 +282,7 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
|
||||
context.event.subscribe(BindViewEvent::class) { event ->
|
||||
event.chatMessage { conversationId, messageId ->
|
||||
val viewGroup = event.view.findParent(maxIteration = 3) {
|
||||
it is LinearLayout
|
||||
} as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage
|
||||
val viewGroup = resolveKeyActionContainer(event.view) ?: return@chatMessage
|
||||
|
||||
viewGroup.findViewWithTag<View>(specialCard)?.also {
|
||||
viewGroup.removeView(it)
|
||||
@@ -289,27 +305,45 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
val publicKey = pkRequests[messageId.toLong()]
|
||||
|
||||
if (publicKey != null || secret != null) {
|
||||
viewGroup.addView(createComposeView(context.mainActivity!!) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
onClick = {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
) {
|
||||
createComposeView(viewGroup.context) {
|
||||
PurrfectOverlayTheme {
|
||||
val actionShape = RoundedCornerShape(22.dp)
|
||||
val borderBrush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.70f),
|
||||
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(translation["accept_public_key_button"])
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(translation["accept_secret_button"])
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(actionShape)
|
||||
.background(PurrfectOverlayPalette.cardOverlay, actionShape)
|
||||
.border(1.15.dp, borderBrush, actionShape)
|
||||
.padding(horizontal = 18.dp, vertical = 11.dp)
|
||||
) {
|
||||
if (publicKey != null) {
|
||||
Text(
|
||||
text = translation["accept_public_key_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
if (secret != null) {
|
||||
Text(
|
||||
text = translation["accept_secret_button"],
|
||||
color = PurrfectOverlayPalette.textPrimary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +353,16 @@ class EndToEndEncryption : MessagingRuleFeature(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
})
|
||||
setOnClickListener {
|
||||
if (publicKey != null) {
|
||||
handlePublicKeyRequest(conversationId, publicKey)
|
||||
}
|
||||
if (secret != null) {
|
||||
handleSecretResponse(conversationId, secret)
|
||||
}
|
||||
}
|
||||
viewGroup.addView(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
))
|
||||
} }
|
||||
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
|
||||
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
|
||||
|
||||
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
|
||||
peekingStateListeners.add(listener)
|
||||
}
|
||||
|
||||
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
|
||||
return runCatching {
|
||||
@@ -207,6 +212,12 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
else -> {}
|
||||
}
|
||||
|
||||
when (eventType) {
|
||||
TrackerEventType.STARTED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, true) }
|
||||
TrackerEventType.STOPPED_PEEKING -> peekingStateListeners.forEach { it(conversationId, userId, false) }
|
||||
else -> {}
|
||||
}
|
||||
|
||||
dispatchEvents(eventType, conversationId, userId)
|
||||
}
|
||||
|
||||
@@ -261,7 +272,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
typing = stateMap[4],
|
||||
wasTyping = stateMap[5],
|
||||
speaking = stateMap[6] && stateMap[4],
|
||||
peeking = stateMap[8]
|
||||
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
|
||||
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,7 +397,8 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
|
||||
override fun init() {
|
||||
val sessionEventsConfig = context.config.friendTracker
|
||||
if (sessionEventsConfig.globalState != true) return
|
||||
val shouldProcessSessionEvents = sessionEventsConfig.globalState == true || peekingStateListeners.isNotEmpty()
|
||||
if (!shouldProcessSessionEvents) return
|
||||
|
||||
if (sessionEventsConfig.allowRunningInBackground.get()) {
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
@@ -402,7 +415,7 @@ class FriendTracker : Feature("Friend Tracker") {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionEventsConfig.recordMessagingEvents.get()) {
|
||||
if (sessionEventsConfig.recordMessagingEvents.get() || peekingStateListeners.isNotEmpty()) {
|
||||
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
|
||||
hook("onReceive", HookStage.BEFORE) { param ->
|
||||
param.setResult(null)
|
||||
|
||||
@@ -6,17 +6,10 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
private val peekingConversations = ConcurrentHashMap<String, List<String>>()
|
||||
private val startPeekingTimestamps = ConcurrentHashMap<String, Long>()
|
||||
private val startPeekingTimestamps = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||
private val halfSwipeListeners = mutableListOf<(String, String, Long) -> Unit>()
|
||||
|
||||
private val notificationManager get() = context.androidContext.getSystemService(NotificationManager::class.java)
|
||||
@@ -39,44 +32,11 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
|
||||
override fun init() {
|
||||
if (context.config.messaging.halfSwipeNotifier.globalState != true) return
|
||||
lateinit var presenceService: Any
|
||||
|
||||
findClass("com.snapchat.talkcorev3.PresenceService\$CppProxy").hookConstructor(HookStage.AFTER) {
|
||||
presenceService = it.thisObject()
|
||||
}
|
||||
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
callbacks.getClass("PresenceServiceDelegate")?.hook("notifyActiveConversationsChanged", HookStage.BEFORE) {
|
||||
val activeConversations = presenceService::class.java.methods.find { it.name == "getActiveConversations" }?.invoke(presenceService) as? Map<*, *> ?: return@hook // conversationId, conversationInfo (this.mPeekingParticipants)
|
||||
|
||||
if (activeConversations.isEmpty()) {
|
||||
peekingConversations.forEach {
|
||||
val conversationId = it.key
|
||||
val peekingParticipantsIds = it.value
|
||||
peekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
peekingConversations.clear()
|
||||
return@hook
|
||||
}
|
||||
|
||||
activeConversations.forEach { (conversationId, conversationInfo) ->
|
||||
val peekingParticipantsIds = (conversationInfo?.getObjectField("mPeekingParticipants") as? List<*>)?.map { it.toString() } ?: return@forEach
|
||||
val cachedPeekingParticipantsIds = peekingConversations[conversationId] ?: emptyList()
|
||||
|
||||
val newPeekingParticipantsIds = peekingParticipantsIds - cachedPeekingParticipantsIds.toSet()
|
||||
val exitedPeekingParticipantsIds = cachedPeekingParticipantsIds - peekingParticipantsIds.toSet()
|
||||
|
||||
newPeekingParticipantsIds.forEach { userId ->
|
||||
startPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
|
||||
exitedPeekingParticipantsIds.forEach { userId ->
|
||||
endPeeking(conversationId.toString(), userId)
|
||||
}
|
||||
peekingConversations[conversationId.toString()] = peekingParticipantsIds
|
||||
}
|
||||
context.feature(FriendTracker::class).addOnPeekingStateChangedListener { conversationId, userId, peeking ->
|
||||
if (peeking) {
|
||||
startPeeking(conversationId, userId)
|
||||
} else {
|
||||
endPeeking(conversationId, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,4 +99,4 @@ class HalfSwipeNotifier : Feature("Half Swipe Notifier") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,18 +117,29 @@ class ConversationManager(
|
||||
set("mServerConversationId", conversationId.toSnapUUID().instanceNonNull())
|
||||
set("mServerMessageId", serverMessageId)
|
||||
}
|
||||
val conversationUuid = conversationId.toSnapUUID().instanceNonNull()
|
||||
|
||||
fetchMessageByServerId.invoke(
|
||||
instanceNonNull(),
|
||||
serverMessageIdentifier,
|
||||
CallbackBuilder(getCallbackClass("FetchMessageCallback"))
|
||||
.override("onFetchMessageComplete") { param ->
|
||||
onSuccess(Message(param.arg(0)))
|
||||
}
|
||||
.override("onError") {
|
||||
onError(it.arg<Any>(0).toString())
|
||||
}.build()
|
||||
)
|
||||
val callback = CallbackBuilder(getCallbackClass("FetchMessageCallback"))
|
||||
.override("onFetchMessageComplete") { param ->
|
||||
onSuccess(Message(param.arg(0)))
|
||||
}
|
||||
.override("onError") {
|
||||
onError(it.arg<Any>(0).toString())
|
||||
}.build()
|
||||
|
||||
val args = fetchMessageByServerId.parameterTypes.mapIndexed { index, parameterType ->
|
||||
when {
|
||||
parameterType.isInstance(serverMessageIdentifier) -> serverMessageIdentifier
|
||||
parameterType.isInstance(callback) -> callback
|
||||
parameterType.isInstance(conversationUuid) -> conversationUuid
|
||||
parameterType == Boolean::class.javaPrimitiveType || parameterType == Boolean::class.javaObjectType -> false
|
||||
else -> throw IllegalStateException(
|
||||
"Unsupported fetchMessageByServerId parameter at index $index: ${parameterType.name}"
|
||||
)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
fetchMessageByServerId.invoke(instanceNonNull(), *args)
|
||||
}
|
||||
|
||||
fun fetchMessagesByServerIds(conversationId: String, serverMessageIds: List<Long>, onSuccess: (List<Message>) -> Unit, onError: (error: String) -> Unit) {
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.4.5
|
||||
APP_VERSION_CODE=285
|
||||
APP_VERSION_NAME=1.4.8
|
||||
APP_VERSION_CODE=288
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user