Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 | ||
|
|
7a8ad81d48 | ||
|
|
7db60d6eb1 | ||
|
|
565a8b8287 | ||
|
|
1c612ebc8e | ||
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
@@ -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(
|
||||
|
||||
@@ -97,6 +97,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
|
||||
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
|
||||
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
@@ -110,13 +117,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
)
|
||||
}
|
||||
|
||||
// The "Structured Glass" Container (1:1 with build 33a7e8f)
|
||||
// The "Structured Glass" Container (Dynamically Morphed)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 12.dp),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
.padding(top = containerTopPadding),
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
@@ -128,7 +135,7 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = controlsHeight,
|
||||
top = controlsHeight - 44.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
|
||||
@@ -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.6").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("286").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.1").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("294").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,22 @@
|
||||
## v1.5.1
|
||||
- Fix: Splitting issue for video snaps sent through gallery media send override!
|
||||
- New: Toggle to turn off/on splitting for video snaps sent through send override
|
||||
- Fix: Aphelion task page layout optimization(tq to Kaladin)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3323,6 +3327,7 @@
|
||||
"title": "Send media as",
|
||||
"duration": "Duration: {duration}",
|
||||
"saveable_snap_hint": "Make Snap saveable in the chat",
|
||||
"single_send_hint": "Send as one snap",
|
||||
"unlimited_duration": "Unlimited",
|
||||
"schedule": "Schedule",
|
||||
"select_time": "Select time",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.CursorWrapper
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
@@ -36,6 +42,7 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.FileType
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
@@ -47,17 +54,284 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.util.dataBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.lang.reflect.Method
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
|
||||
class MediaFilePicker : Feature("Media File Picker") {
|
||||
companion object {
|
||||
private const val SNAP_CHUNK_DURATION_MS = 10_000L
|
||||
private val queuedSplitItems = ArrayDeque<Any>()
|
||||
private val queuedSplitItemIds = ArrayDeque<String>()
|
||||
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
|
||||
private var originalUnsplitItem: Any? = null
|
||||
private var queuedOverrideType: String? = null
|
||||
private var bypassSplitOnce = false
|
||||
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
|
||||
private var cleanupItemHandler: ((String) -> Unit)? = null
|
||||
|
||||
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
|
||||
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
|
||||
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
|
||||
fun setQueuedOverrideType(value: String?) {
|
||||
queuedOverrideType = value
|
||||
}
|
||||
fun getQueuedOverrideType(): String? = queuedOverrideType
|
||||
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
|
||||
if (deleteTempItems) {
|
||||
val cleanup = cleanupItemHandler
|
||||
queuedSplitCleanupUris.values.toList().forEach { uri ->
|
||||
cleanup?.invoke(uri)
|
||||
}
|
||||
}
|
||||
queuedSplitItems.clear()
|
||||
queuedSplitItemIds.clear()
|
||||
queuedSplitCleanupUris.clear()
|
||||
originalUnsplitItem = null
|
||||
queuedOverrideType = null
|
||||
}
|
||||
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
|
||||
clearQueuedSplitItems(deleteTempItems = false)
|
||||
originalUnsplitItem = originalItem
|
||||
items.drop(1).forEach { queuedSplitItems.addLast(it) }
|
||||
preparedItems.forEach {
|
||||
queuedSplitItemIds.addLast(it.itemId)
|
||||
queuedSplitCleanupUris[it.itemId] = it.uri
|
||||
}
|
||||
}
|
||||
fun sendOriginalUnsplitItem(): Boolean {
|
||||
val item = originalUnsplitItem ?: return false
|
||||
val overrideType = queuedOverrideType
|
||||
clearQueuedSplitItems(deleteTempItems = true)
|
||||
queuedOverrideType = overrideType
|
||||
bypassSplitOnce = true
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
return sender(item)
|
||||
}
|
||||
fun handleCurrentQueuedItemSuccess(): Boolean {
|
||||
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
|
||||
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
|
||||
cleanupItemHandler?.invoke(uri)
|
||||
}
|
||||
}
|
||||
if (queuedSplitItems.isEmpty()) {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val next = queuedSplitItems.removeFirstOrNull() ?: run {
|
||||
queuedOverrideType = null
|
||||
return false
|
||||
}
|
||||
val sender = sendSingleItemHandler ?: return false
|
||||
val result = sender(next)
|
||||
if (!result) {
|
||||
queuedSplitItems.addFirst(next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var lastMediaDuration: Long? = null
|
||||
private set
|
||||
|
||||
private data class PreparedMediaItem(
|
||||
val itemId: String,
|
||||
val durationMs: Long,
|
||||
val uri: String
|
||||
)
|
||||
|
||||
private fun splitVideoIntoChunks(
|
||||
inputFile: File,
|
||||
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
|
||||
): List<File> {
|
||||
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
|
||||
if (durationMs <= chunkDurationMs) return listOf(inputFile)
|
||||
|
||||
val retriever = MediaMetadataRetriever()
|
||||
val rotation = runCatching {
|
||||
retriever.setDataSource(inputFile.absolutePath)
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
||||
}.getOrDefault(0).also {
|
||||
runCatching { retriever.release() }
|
||||
}
|
||||
|
||||
val outputFiles = mutableListOf<File>()
|
||||
var chunkStartMs = 0L
|
||||
var chunkIndex = 0
|
||||
|
||||
while (chunkStartMs < durationMs) {
|
||||
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
|
||||
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
|
||||
val extractor = MediaExtractor()
|
||||
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
val trackMap = mutableMapOf<Int, Int>()
|
||||
|
||||
try {
|
||||
extractor.setDataSource(inputFile.absolutePath)
|
||||
|
||||
repeat(extractor.trackCount) { trackIndex ->
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
|
||||
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
|
||||
extractor.selectTrack(trackIndex)
|
||||
trackMap[trackIndex] = muxer.addTrack(format)
|
||||
}
|
||||
|
||||
if (rotation != 0) {
|
||||
muxer.setOrientationHint(rotation)
|
||||
}
|
||||
|
||||
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
|
||||
extractor.getTrackFormat(trackIndex).let { format ->
|
||||
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
} else {
|
||||
1024 * 1024
|
||||
}
|
||||
}
|
||||
} ?: (1024 * 1024)
|
||||
|
||||
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
|
||||
val bufferInfo = android.media.MediaCodec.BufferInfo()
|
||||
muxer.start()
|
||||
|
||||
extractor.seekTo(chunkStartMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
|
||||
while (true) {
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = extractor.readSampleData(buffer, 0)
|
||||
if (bufferInfo.size < 0) break
|
||||
|
||||
val sampleTimeUs = extractor.sampleTime
|
||||
if (sampleTimeUs < 0) break
|
||||
if (sampleTimeUs >= chunkEndMs * 1000) break
|
||||
|
||||
val sampleTrackIndex = extractor.sampleTrackIndex
|
||||
val muxerTrackIndex = trackMap[sampleTrackIndex]
|
||||
if (muxerTrackIndex != null) {
|
||||
bufferInfo.presentationTimeUs = sampleTimeUs - (chunkStartMs * 1000)
|
||||
bufferInfo.flags = extractor.sampleFlags
|
||||
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
|
||||
}
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
outputFiles += outputFile
|
||||
} catch (throwable: Throwable) {
|
||||
outputFile.delete()
|
||||
outputFiles.forEach { it.delete() }
|
||||
throw throwable
|
||||
} finally {
|
||||
runCatching { muxer.stop() }
|
||||
runCatching { muxer.release() }
|
||||
runCatching { extractor.release() }
|
||||
}
|
||||
|
||||
chunkStartMs += chunkDurationMs
|
||||
chunkIndex++
|
||||
}
|
||||
|
||||
return outputFiles
|
||||
}
|
||||
|
||||
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
|
||||
val resolver = context.androidContext.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
|
||||
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("Failed to create MediaStore entry")
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
file.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("Failed to open MediaStore output stream")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.update(uri, ContentValues().apply {
|
||||
put(MediaStore.Video.Media.IS_PENDING, 0)
|
||||
}, null, null)
|
||||
}
|
||||
}.onFailure {
|
||||
resolver.delete(uri, null, null)
|
||||
throw it
|
||||
}
|
||||
|
||||
val durationMs = extractMediaDuration(uri) ?: 0L
|
||||
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
|
||||
|
||||
context.coroutineScope.launch {
|
||||
delay(120_000)
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
|
||||
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
|
||||
}
|
||||
|
||||
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
|
||||
return mediaItems.mapIndexedNotNull { index, mediaItem ->
|
||||
itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", mediaItem.durationMs.toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", mediaItem.itemId)
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", index.toDouble())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
|
||||
val numericId = itemId.toLongOrNull() ?: return null
|
||||
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
|
||||
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
) ?: return null
|
||||
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
|
||||
|
||||
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
|
||||
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
|
||||
|
||||
return runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
|
||||
sourceFile.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: error("Failed to open source gallery video")
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
chunkFiles.forEach { if (it != sourceFile) it.delete() }
|
||||
preparedItems
|
||||
}.also {
|
||||
sourceFile.delete()
|
||||
}.getOrElse {
|
||||
context.log.error("Failed to prepare split gallery items", it)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractMediaDuration(uri: Uri): Long? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
return runCatching {
|
||||
@@ -96,6 +370,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
var sendItemsMethod: Method? = null
|
||||
var drawerViewClass: Class<*>? = null
|
||||
var sendItemsListItemClassFallback: Class<*>? = null
|
||||
var sendItemsHookedHandler: Any? = null
|
||||
|
||||
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
|
||||
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
|
||||
@@ -115,6 +390,71 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
sendItemsMethod = sendItems
|
||||
handlerParamMethod.hook(HookStage.AFTER) {
|
||||
chatMediaDrawerActionHandler = it.arg(0)
|
||||
val handlerInstance = chatMediaDrawerActionHandler
|
||||
sendSingleItemHandler = sendSingleItem@{ item ->
|
||||
runCatching {
|
||||
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
true
|
||||
}.getOrElse { throwable ->
|
||||
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
|
||||
false
|
||||
}
|
||||
}
|
||||
cleanupItemHandler = { uriString ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
|
||||
}.onFailure {
|
||||
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
|
||||
}
|
||||
}
|
||||
if (sendItemsHookedHandler === handlerInstance) return@hook
|
||||
sendItemsHookedHandler = handlerInstance
|
||||
|
||||
Hooker.hookObjectMethod(
|
||||
handlerInstance::class.java,
|
||||
handlerInstance,
|
||||
sendItemsName,
|
||||
HookStage.BEFORE
|
||||
) { param ->
|
||||
if (bypassSplitOnce) {
|
||||
bypassSplitOnce = false
|
||||
return@hookObjectMethod
|
||||
}
|
||||
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
|
||||
if (currentItems.isEmpty()) return@hookObjectMethod
|
||||
|
||||
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
?: currentItems.firstOrNull()?.javaClass
|
||||
?: return@hookObjectMethod
|
||||
|
||||
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
|
||||
var didExpand = false
|
||||
val expandedItems = currentItems.flatMap { item ->
|
||||
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
|
||||
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
|
||||
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
|
||||
?: 0L
|
||||
val itemId = baseItem.getObjectFieldOrNull("_itemId")
|
||||
?.getObjectFieldOrNull("_itemId")
|
||||
?.toString()
|
||||
?: return@flatMap listOf(item)
|
||||
|
||||
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
|
||||
if (splitItems.isNullOrEmpty()) {
|
||||
listOf(item)
|
||||
} else {
|
||||
didExpand = true
|
||||
preparedExpandedItems.addAll(splitItems)
|
||||
buildDrawerItems(itemClass, splitItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (didExpand && expandedItems.isNotEmpty()) {
|
||||
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
|
||||
param.setArg(1, listOf(expandedItems.first()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +511,7 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
fun sendMedia() {
|
||||
fun sendMedia(items: List<PreparedMediaItem>? = null) {
|
||||
val method = sendItemsMethod ?: return
|
||||
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
|
||||
?: sendItemsListItemClassFallback
|
||||
@@ -180,27 +520,13 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
|
||||
return
|
||||
}
|
||||
val item = itemClass.dataBuilder {
|
||||
from("_item") {
|
||||
set("_cameraRollSource", "Snapchat")
|
||||
set("_contentUri", "")
|
||||
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
|
||||
set("_disabled", false)
|
||||
set("_imageRotation", 0.0)
|
||||
set("_width", 1080.0)
|
||||
set("_height", 1920.0)
|
||||
set("_timestampMs", System.currentTimeMillis().toDouble())
|
||||
from("_itemId") {
|
||||
set("_itemId", firstVideoId.toString())
|
||||
set("_type", "VIDEO")
|
||||
}
|
||||
}
|
||||
set("_order", 0.0)
|
||||
} ?: run {
|
||||
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
|
||||
val builtItems = buildDrawerItems(itemClass, mediaItems)
|
||||
if (builtItems.size != mediaItems.size) {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
|
||||
return
|
||||
}
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
|
||||
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
|
||||
}
|
||||
|
||||
fun startConversion(audioOnly: Boolean) {
|
||||
@@ -238,8 +564,25 @@ class MediaFilePicker : Feature("Media File Picker") {
|
||||
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
|
||||
|
||||
runCatching {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
|
||||
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
|
||||
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
|
||||
convertedFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
val chunkFiles = splitVideoIntoChunks(convertedFile)
|
||||
val preparedItems = chunkFiles.mapIndexed { index, file ->
|
||||
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
|
||||
}
|
||||
|
||||
chunkFiles.forEach { if (it != convertedFile) it.delete() }
|
||||
convertedFile.delete()
|
||||
|
||||
sendMedia(preparedItems)
|
||||
} else {
|
||||
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
|
||||
sendMedia()
|
||||
}
|
||||
}.onFailure {
|
||||
mediaInputStream = null
|
||||
context.log.error(it)
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -32,17 +32,22 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
|
||||
import me.eternal.purrfectsnap.core.messaging.MessageSender
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
|
||||
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
|
||||
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
|
||||
import me.eternal.purrfectsnap.core.util.CallbackBuilder
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.Hooker
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
@@ -54,9 +59,11 @@ import kotlin.time.toDuration
|
||||
class SendOverride : Feature("Send Override") {
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
|
||||
private val internalMultipartSend = ThreadLocal.withInitial { false }
|
||||
}
|
||||
|
||||
private var selectedType by mutableStateOf("SNAP")
|
||||
private var disableSplitForCurrentSend by mutableStateOf(false)
|
||||
private var customDuration by mutableFloatStateOf(10f)
|
||||
private var scheduledTime by mutableStateOf<Long?>(null)
|
||||
private var showClockPicker by mutableStateOf(false)
|
||||
@@ -66,7 +73,6 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
|
||||
private fun acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -362,7 +368,12 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
|
||||
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
|
||||
}
|
||||
|
||||
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
|
||||
if (internalMultipartSend.get() == true) return@subscribe
|
||||
postSavePolicy = null
|
||||
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
|
||||
val localMessageContent = event.messageContent
|
||||
@@ -401,9 +412,40 @@ class SendOverride : Feature("Send Override") {
|
||||
ev.canceled = false
|
||||
}
|
||||
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
val sendMessageCallbackClass by lazy {
|
||||
lateinit var result: Class<*>
|
||||
context.mappings.useMapper(CallbackMapper::class) {
|
||||
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fun cloneDestinations(source: MessageDestinations): Any {
|
||||
return context.gson.fromJson(
|
||||
context.gson.toJson(source.instanceNonNull()),
|
||||
context.classCache.messageDestinations
|
||||
)
|
||||
}
|
||||
|
||||
val sendMessageWithContentMethod by lazy {
|
||||
sequence {
|
||||
var current: Class<*>? = context.classCache.conversationManager
|
||||
while (current != null && current != Any::class.java && current != Object::class.java) {
|
||||
yield(current)
|
||||
current = current.superclass
|
||||
}
|
||||
}.flatMap { it.declaredMethods.asSequence() }
|
||||
.first { it.name == "sendMessageWithContent" }
|
||||
}
|
||||
|
||||
fun applyOverride(
|
||||
targetMessageContent: MessageContent,
|
||||
targetReader: ProtoReader,
|
||||
overrideType: String,
|
||||
snapDurationMs: Int?
|
||||
): Boolean {
|
||||
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Default.WarningAmber,
|
||||
context.translation["gallery_media_send_override.multiple_media_toast"]
|
||||
@@ -416,10 +458,10 @@ class SendOverride : Feature("Send Override") {
|
||||
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
|
||||
postSavePolicy = savePolicyValue
|
||||
|
||||
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
|
||||
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
|
||||
|
||||
if (localMessageContent.contentType != ContentType.SNAP) {
|
||||
localMessageContent.content = ProtoWriter().apply {
|
||||
if (targetMessageContent.contentType != ContentType.SNAP) {
|
||||
targetMessageContent.content = ProtoWriter().apply {
|
||||
from(11) {
|
||||
from(5) {
|
||||
from(1) {
|
||||
@@ -440,11 +482,11 @@ class SendOverride : Feature("Send Override") {
|
||||
}.toByteArray()
|
||||
}
|
||||
|
||||
localMessageContent.contentType = ContentType.SNAP
|
||||
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
|
||||
targetMessageContent.contentType = ContentType.SNAP
|
||||
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
|
||||
edit(11, 5, 2) {
|
||||
arrayOf(6, 7, 8).forEach { remove(it) }
|
||||
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
|
||||
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
|
||||
addVarInt(8, snapDurationMs / 1000)
|
||||
if (snapDurationMs / 1000 <= 0) {
|
||||
@@ -474,11 +516,11 @@ class SendOverride : Feature("Send Override") {
|
||||
if (shouldPreventSave) {
|
||||
postSavePolicy = 1 // PROHIBITED
|
||||
}
|
||||
localMessageContent.contentType = ContentType.NOTE
|
||||
targetMessageContent.contentType = ContentType.NOTE
|
||||
val stripMeta = context.config.messaging.stripMediaMetadata.get()
|
||||
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
|
||||
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
|
||||
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
|
||||
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
|
||||
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
|
||||
val audioNoteProto = MessageSender.audioNoteProto(
|
||||
@@ -487,7 +529,7 @@ class SendOverride : Feature("Send Override") {
|
||||
)
|
||||
|
||||
// Set save policy in the proto if prevent audio is enabled
|
||||
localMessageContent.content = if (shouldPreventSave) {
|
||||
targetMessageContent.content = if (shouldPreventSave) {
|
||||
// Check which path structure exists in the audio note proto
|
||||
val protoReader = ProtoReader(audioNoteProto)
|
||||
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
|
||||
@@ -519,7 +561,7 @@ class SendOverride : Feature("Send Override") {
|
||||
Class.forName(
|
||||
"com.snapchat.client.messaging.SavePolicy",
|
||||
false,
|
||||
localMessageContent.instanceNonNull().javaClass.classLoader
|
||||
targetMessageContent.instanceNonNull().javaClass.classLoader
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
@@ -537,7 +579,7 @@ class SendOverride : Feature("Send Override") {
|
||||
}.getOrNull()
|
||||
|
||||
if (policyEnum != null) {
|
||||
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,10 +591,111 @@ class SendOverride : Feature("Send Override") {
|
||||
return true
|
||||
}
|
||||
|
||||
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
|
||||
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
|
||||
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
|
||||
if (overrideType != "ORIGINAL" && mediaCount > 1) {
|
||||
val originalJson = context.gson.toJson(localMessageContent.instanceNonNull())
|
||||
val originalCallback = event.adapter.args().getOrNull(2)
|
||||
val mediaBuffers = mutableListOf<ByteArray>()
|
||||
messageProtoReader.followPath(3)?.eachBuffer { id, buffer ->
|
||||
if (id == 3) mediaBuffers.add(buffer)
|
||||
}
|
||||
if (mediaBuffers.isEmpty()) return false
|
||||
|
||||
fun buildPartMessageContent(partIndex: Int): MessageContent {
|
||||
val partContent = MessageContent(
|
||||
context.gson.fromJson(originalJson, context.classCache.localMessageContent)
|
||||
)
|
||||
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
|
||||
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
|
||||
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
|
||||
partContent.content = ProtoEditor(partContent.content!!).apply {
|
||||
edit(3) {
|
||||
remove(3)
|
||||
addBuffer(3, mediaBuffers[partIndex])
|
||||
}
|
||||
}.toByteArray()
|
||||
if (partIndex < refs.size) {
|
||||
partContent.localMediaReferences = arrayListOf(refs[partIndex])
|
||||
}
|
||||
metadata?.let {
|
||||
if (contentRefs != null && partIndex < contentRefs.size) {
|
||||
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
|
||||
}
|
||||
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
|
||||
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
|
||||
}
|
||||
}
|
||||
return partContent
|
||||
}
|
||||
|
||||
fun sendPart(partIndex: Int) {
|
||||
postSavePolicy = null
|
||||
val partContent = buildPartMessageContent(partIndex)
|
||||
val partReader = ProtoReader(partContent.content ?: return)
|
||||
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
|
||||
|
||||
val callback = if (partIndex == mediaCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
CallbackBuilder(sendMessageCallbackClass)
|
||||
.override("onSuccess") {
|
||||
sendPart(partIndex + 1)
|
||||
}
|
||||
.override("onError", shouldUnhook = false) {
|
||||
runCatching {
|
||||
originalCallback?.javaClass?.methods?.firstOrNull { method ->
|
||||
method.name == "onError" && method.parameterCount == 1
|
||||
}?.invoke(originalCallback, it.argNullable<Any>(0))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
if (partIndex == 0) {
|
||||
event.adapter.setArg(1, partContent.instanceNonNull())
|
||||
event.adapter.setArg(2, callback)
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
} else {
|
||||
internalMultipartSend.set(true)
|
||||
try {
|
||||
sendMessageWithContentMethod.invoke(
|
||||
context.feature(Messaging::class).conversationManager?.instanceNonNull(),
|
||||
cloneDestinations(event.destinations),
|
||||
partContent.instanceNonNull(),
|
||||
callback
|
||||
)
|
||||
} finally {
|
||||
internalMultipartSend.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendPart(0)
|
||||
return true
|
||||
}
|
||||
|
||||
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
|
||||
}
|
||||
|
||||
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
|
||||
?: configOverrideType?.takeIf { it != "always_ask" }
|
||||
if (resolvedOverrideType != null) {
|
||||
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null) {
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
event.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
if (sendMedia(resolvedOverrideType, 10000)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (event.canceled) invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
return@subscribe
|
||||
}
|
||||
@@ -713,6 +856,21 @@ class SendOverride : Feature("Send Override") {
|
||||
fun toggleSaveable() {
|
||||
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
disableSplitForCurrentSend = !disableSplitForCurrentSend
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = disableSplitForCurrentSend,
|
||||
onCheckedChange = {
|
||||
disableSplitForCurrentSend = it
|
||||
}
|
||||
)
|
||||
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
toggleSaveable()
|
||||
@@ -915,6 +1073,25 @@ class SendOverride : Feature("Send Override") {
|
||||
Button(onClick = {
|
||||
alertDialog.dismiss()
|
||||
val finalSelectedType = selectedType
|
||||
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
|
||||
MediaFilePicker.setQueuedOverrideType(null)
|
||||
}
|
||||
return@Button
|
||||
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
|
||||
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
|
||||
event.addCallbackResult("onSuccess") {
|
||||
context.runOnUiThread {
|
||||
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
event.addCallbackResult("onError") {
|
||||
MediaFilePicker.clearQueuedSplitItems()
|
||||
}
|
||||
}
|
||||
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
|
||||
if (delayMs != null && delayMs > 0) {
|
||||
val taskHash = java.util.UUID.randomUUID().toString()
|
||||
@@ -961,7 +1138,9 @@ class SendOverride : Feature("Send Override") {
|
||||
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
|
||||
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (event.canceled) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
|
||||
context.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
@@ -1009,7 +1188,9 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
} else {
|
||||
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
if (event.canceled) {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.4.6
|
||||
APP_VERSION_CODE=286
|
||||
APP_VERSION_NAME=1.5.1
|
||||
APP_VERSION_CODE=294
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user